zenstackhq / zenstack

Fullstack TypeScript toolkit that enhances Prisma ORM with flexible Authorization layer for RBAC/ABAC/PBAC/ReBAC, offering auto-generated type-safe APIs and frontend hooks.
https://zenstack.dev
MIT License
2.07k stars 88 forks source link

Support for trpc-openapi? #217

Closed digoburigo closed 1 year ago

digoburigo commented 1 year ago

Is your feature request related to a problem? Please describe. I want to have some tRPC methods exposed as RESTful endpoints

Describe the solution you'd like Integration with the trpc-openapi

Describe alternatives you've considered The alternative would be when regenerating the tRPC methods don't overwrite them, so that my changes based on trpc-openapi stay there

Additional context I don't know how authentication and validation would work with trpc-openapi

OBS: the types in meta method from trpc-openapi on the generated procedures from don't work properly

ymc9 commented 1 year ago

Thanks for filing this @digoburigo .

I just briefly checked trpc-openapi and will need to look deeper into how to integrate. Meanwhile, is it more convenient to have a native restful support? ZenStack already has a next.js integration which exposes Primsa CRUD operations as rest-like api endpoints (with Prima query syntax as payload), and integrating with another server should be easy to achieve.

What server are you targeting now?

digoburigo commented 1 year ago

The goal is to expose REST endpoints for external third party clients, but in all internal tools use only tRPC. The plugin for next is good, but the flexibilty of trpc-openapi it's better for my case.

The server that i'm going to use it's fastify

ymc9 commented 1 year ago

Got it. Thanks for the information. Let me research a bit more and update back here soon.

digoburigo commented 1 year ago

Ok. Thanks for your time!

ymc9 commented 1 year ago

Hey @digoburigo ,

I've been thinking about this and wanted to check for your thoughts on the direction. Right now the trpc routers generated simply mirrors Prisma API and doesn't look RESTful. So I'm wondering if it makes sense to expose them directly as openapi.

Alternatively, we can head the direction where you manually define the trpc routers and corresponding openapi meta, and use the zenstack-enhanced Prisma client for automatic authorization. This way you can have flexibility on how the input and output are shaped in the apis.

E.g.:

Zmodel

model User {
  id String @id @default(cuid())
  email String @unique
  posts Post[]

  // policies
  @@allow('create,read', true)
}

model Post {
  id String @id @default(cuid())
  title String
  owner User @relation(fields: [ownerId], references: [id])
  ownerId String

  // policies
  @@allow('all', auth() == owner)
}

TRPC Setup

const createContext = ({
    req,
    res,
}: trpcExpress.CreateExpressContextOptions) => {
    // replace this with logic for fetching the user from req
    const user = req.headers.authorization
        ? { id: req.headers.authorization }
        : undefined;
    return {
        req,
        res,
        prisma: withPresets(prisma, { user }), // put an authorization-enabled prisma into context
    };
};
type Context = inferAsyncReturnType<typeof createContext>;

const t = initTRPC.meta<OpenApiMeta>().context<Context>().create();

const router = t.router;

// TODO: generated by zenstack in the future
const zodUser = z.object({
    id: z.string(),
    email: z.string().email(),
});

const zodPost = z.object({
    id: z.string(),
    title: z.string(),
    ownerId: z.string(),
});

const userRouter = router({
    createUser: t.procedure
        .meta({ openapi: { method: 'POST', path: '/users' } })
        .input(UserSchema.create)  // UserSchema is generated by @zenstackhq/trpc plugin
        .output(zodUser)
        .mutation(async ({ input, ctx }) => {
            const user = await ctx.prisma.user.create(input);
            return user;
        }),
});

const postRouter = router({
    createPost: t.procedure
        .meta({ openapi: { method: 'POST', path: '/posts' } })
        .input(PostSchema.create) // PostSchema is generated by @zenstackhq/trpc plugin
        .output(zodPost)
        .mutation(async ({ input, ctx }) => {
            // only creating post for the current user is allowed
            const post = await ctx.prisma.post.create(input);
            return post;
            t;
        }),
    listPosts: t.procedure
        .meta({ openapi: { method: 'GET', path: '/posts' } })
        .input(z.object({}))
        .output(z.object({ data: z.array(zodPost) }))
        // only posts of the current user are returned
        .query(async ({ ctx }) => ({ data: await ctx.prisma.post.findMany() })),
});

Does this make sense to you? The only missing part for this approach is that today zod schemas for the entity types (User, Post) are not generated. This wasn't a problem for regular trpc usage, but trpc-openapi requires an explicit output schema. I can add a separate "@zenstackhq/zod" plugin dedicated to schema generation decoupled with trpc.

Let me know your thoughts.

digoburigo commented 1 year ago

Yeah. Making a router manually is a great thing for flexibility. The permissions from the zenstack-enhanced Prisma and the generated outputs zod schemas it's really neat in this flow.

But I have one question. In the generated tRPC methods, could I just set a flag or a comment in a tRPC method, so that when regenerating methods they don't get overwritten? If so, I would just set the trpc-openapi stuff in the generated methods and that's it. What do you thing about this?

Thanks for your response!

ymc9 commented 1 year ago

Got it. I'm just feeling that partly preserving content during regeneration can be complicated and fragile.

We can also consider supporting trpc-openapi natively, given its popularity, like:

model Post {
  id String @id @default(cuid())
  title String
  owner User @relation(fields: [ownerId], references: [id])
  ownerId String

  @@trpc.openApiMeta
}

The @@trpc.openApiMeta triggers the generation of .meta() call with fixed rules for method, path, summary, etc. Do you need any customization ability for such properties?

digoburigo commented 1 year ago

I'm just feeling that partly preserving content during regeneration can be complicated and fragile.

Yeah. I agree with that too.

@@trpc.openApiMeta Wow. if this is possible would be the ideal implementation I think.

Would be nice to have the customization aspect too. If none of the properties is given in @@trpc.openApiMeta would generate a default one, but if you pass some props would use the ones you passed.

The only thing I'm not grasping is how you would customize each CRUD RESTfull endpoint created by this flag, maybe and array of config?

First iteration that I thought

model Post {
  id String @id @default(cuid())
  title String
  owner User @relation(fields: [ownerId], references: [id])
  ownerId String

  @@trpc.openApiMeta({
     create: {
          method: "POST",
          path: "post",
          ...
     },
     update: {
          method: "PUT", // parcial update with PATCH?
          path: "post",
          ...
     }
  })
}

This would be really cool to use though

ymc9 commented 1 year ago

Unfortunately, attribute argument doesn't support objects yet 😂. Probably time to extend it ...

I feel every prisma CRUD method has a clear corresponding HTTP verb. Even for "update", from Prisma perspective it's always a partial update, so we can fix it to "PATCH".

Here's the mapping:

find*/count/aggregate/groupBy: GET create/createMany/upsert: POST update/updateMany: PATCH delete/deleteMany: DELETE

digoburigo commented 1 year ago

I guess I'm overthinking. For the generated routers the default with @@trpc.openApiMeta is already good. If I need a customization I would make it in a custom router anyway, so I don't know if the customization is necessary. OBS: There is some fields that probably would need customization though, like 'description' for example. So it's a tricky one :)

ymc9 commented 1 year ago

I slept on it and have a new idea 😄. It's probably cleaner to separate the support to trpc and openapi. We can implement a separate "openapi" plugin to generate openapi spec (V3 only?). You can load that into swagger-ui to get documentation. The "openapi" plugin should given you fine-grained settings for top-level and path-level generation control, close to what you proposed previously with the object syntax. I think then it should have parity with trpc-openapi in terms of documentation generation.

plugin openapi {
  provider = '@zenstackhq/openapi'
  output = 'src/openapi/schema.json'
  title = 'My API'
  version = '1.0.0'
  ...
}

model Post {
  @@openapi.spec({
    create: {
      method: "POST",
        path: "post",
        ...
    }
  ...
  })
}

For the RESTful service, we can provide a fastify adapter to serve CRUD operations, like:

import { requestHandler } from '@zenstackhq/fastify';
import { withPresets } from '@zenstackhq/runtime';
import prisma from './db';

// trpc server
fastify.register(fastifyTRPCPlugin, {
    prefix: '/api/trpc',
    trpcOptions: { router: appRouter, createContext },
});

// restful server
fastify.route(requestHandler({
  endpoint: '/api/openapi',
  getPrisma: async (request, reply) => withPresets(prisma, getAuthUser(request))
}));

Since zmodel is the single source of truth, running two separate API services doesn't incur additional development costs. There's also no overhead and complexity brought by trpc-openapi anymore. You can continue using trpc-openapi for other manually implemented trpc routers and have them served under separate API endpoints. Just no need to do that for database crud anymore.

How does this sound to you?

digoburigo commented 1 year ago

Oh, nice. So it would create pure RESTful endpoints without trpc? That's neat, because decouple the logic from tRPC and I can have different things in REST if I want to. That's the best flexibility I think.

Yeah, for me V3 only it's good because I don't have the requirement to maintain old versions.

And like you said, if I want trpc-openapi I have that separate option too

ymc9 commented 1 year ago

Right. Pure restful without trpc. In fact the adapter for next.js is almost that, just need some tweaks to make it conform to openapi.

I'll head this direction then. Thanks a lot for your continuously sharing thoughts. I think we can expect the first working version to land in a few days.

ymc9 commented 1 year ago

resolved by #315