trpc-openapi icon indicating copy to clipboard operation
trpc-openapi copied to clipboard

RFC: Add Model Schemas to OpenApi Docs

Open chuckstock opened this issue 3 years ago • 9 comments

First, I'm loving the project so far and I really appreciate all the work you've done already.

I honestly don't mind the output requirement on a query/mutation, however, it would be nice to be able to define Model Schema's and reference those for the outputs inside the docs. (ex. Pet Model from Redoc).

chuckstock avatar Sep 07 '22 00:09 chuckstock

Hi @chuckstock. I have added this feature as a "Maybe" in our v1.0.0 roadmap (https://github.com/jlalmes/trpc-openapi/issues/91). Any ideas how you would like this API to look/behave?

Initial thoughts 👇

// models.ts
export const User = z.object({
  id: z.string().uuid(),
  name: z.string(),
});

// router.ts
import { User } from './models';
export const appRouter = trpc.router().query('getUsers', {
  meta: { openapi: { ... } },
  input: ...,
  output: z.array(User),
  resolve: ...
});

// openapi.json.ts
import { User } from './models';
import { appRouter } from './router';

const openapi = generateOpenApiDocument(appRouter, {
  ...,
  models: [User]
});

cc @StefanTerdell.

jlalmes avatar Sep 07 '22 13:09 jlalmes

Yeah this is almost exactly what I was thinking and was potentially expecting to find when reviewing the documentation. I think that makes a lot of sense without adding really any extra boilerplate or overhead.

Another package allows you to extend ZodObjects and turn them into openapi components, here is an example from their npm packaage:

import { extendApi, generateSchema } from '@anatine/zod-openapi';
const aZodExtendedSchema = extendApi(
      z.object({
        uid: extendApi(z.string().nonempty(), {
          title: 'Unique ID',
          description: 'A UUID generated by the server',
        }),
        firstName: z.string().min(2),
        lastName: z.string().optional(),
        email: z.string().email(),
        phoneNumber: extendApi(z.string().min(10), {
          description: 'US Phone numbers only',
          example: '555-555-5555',
        }),
      }),
      {
        title: 'User',
        description: 'A user schema',
      }
    );
const myOpenApiSchema = generateSchema(aZodExtendedSchema);

This is another suggestion, although I think I prefer your original suggestion for the API.

chuckstock avatar Sep 07 '22 15:09 chuckstock

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Nov 06 '22 16:11 stale[bot]

Hi, any updates on this?

OscBacon avatar Jul 10 '23 15:07 OscBacon

I am also very interested in this, its the only open issue on the previous roadmap. Can this still be looked at for a future release?

rehanvdm avatar Aug 31 '23 13:08 rehanvdm

From PR https://github.com/jlalmes/trpc-openapi/pull/417 :

I needed a way to integrate my OpenAPI-extended Zod schemas (using @asteasolutions/zod-to-openapi) with the rest of the document generated by trpc-openapi, so I threw this together in an afternoon.

This implementation integrates a registry of Zod schemas into the final document, and also links return, parameter, and request body types of tRPC procedures with these schemas.

A minimal example of how this looks with @asteasolutions/zod-to-openapi (although it should work with any solution):

import { OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import { setZodComponentDefinitions, setZodComponentSchemaGenerator } from "trpc-openapi";
import { z } from "zod";

const registry = new OpenAPIRegistry();
registry.register("ComponentName", z.object({
    // ...
}))

const generator = new OpenApiGeneratorV3(registry.definitions);

// ~~~ FUNCTIONS FROM THE PR BELOW ~~~

// The definitions are used to map the types referenced in tRPC procedures
setZodComponentDefinitions(registry.definitions.reduce((acc, d) => {
    if (d.type === 'schema') {
        acc[d.schema._def.openapi._internal.refId] = d.schema;
    }
    return acc;
}, {} as { [key: string]: z.ZodType }));

// The schema generator generates the actual components in the OpenAPI document
setZodComponentSchemaGenerator(() => generator.generateDocument(config).components?.schemas ?? {});

calasanmarko avatar Oct 26 '23 12:10 calasanmarko

I'm a bit late on the ball here haha. GHs UI sure is one of the UIs of all time

Dont know much about open api but I believe it moves the reused schemas out of what would be the idiomatic JSON schema position ("#/definitions" or "#/$defs"). But that's what the definitionPath option is for. It still presumes the root path to be the schema root though, but you can get around that with basePath.

Here's a basic example:

const baz = z.object({})

zodToJsonSchema(z.object({ x: baz }), {
  basePath: ["foo"],
  definitionPath: "bar",
  definitions: { baz },
})

>

{
  type: 'object',
  properties: { x: { '$ref': 'foo/bar/baz' } },
  required: [ 'x' ],
  additionalProperties: false,
  bar: {
    baz: { type: 'object', properties: {}, additionalProperties: false }
  }
}

You should be able to just move out the definitions object ("bar" in the example) from the resulting schema to put wherever you want in the open api doc as long as you can know the path beforehand and point at it with definitionsPath

StefanTerdell avatar Jan 16 '24 20:01 StefanTerdell

Actually here's a very rough proof of concept @jlalmes

import { zodToJsonSchema } from "zodToJsonSchema";
import { ZodSchema, z } from "zod";

function buildDefinition(
  paths: Record<
    string,
    Record<
      "get" | "post",
      { summary: string; responses: Record<number, ZodSchema<any>> }
    >
  >,
  schemas: Record<string, ZodSchema<any>>,
) {
  const result: any = {
    openapi: "3.0.0",
    paths: {},
    components: {
      schemas: {},
    },
  };

  for (const path in paths) {
    result.paths[path] ??= {};

    for (const method in paths[path]) {
      result.paths[path][method] ??= { summary: paths[path][method].summary };

      for (const response in paths[path][method].responses) {
        result.paths[path][method].responses ??= {};

        const zodSchema = paths[path][method].responses[response];
        const jsonSchema = zodToJsonSchema(zodSchema, {
          target: "openApi3",
          basePath: ["#", "components"],
          definitionPath: "schemas",
          definitions: schemas,
        });

        result.components.schemas = {
          ...result.components.schemas,
          ...(jsonSchema as any).schemas,
        };

        delete (jsonSchema as any).schemas;

        result.paths[path][method].responses[response] = jsonSchema;
      }
    }
  }

  return result;
}

// Component to be reused
const todoSchema = z.object({
  id: z.string().uuid(),
  content: z.string(),
  completed: z.boolean().optional(),
});

// The final openapi.json
const result = buildDefinition(
  {
    todos: {
      get: { summary: "Get a todo", responses: { 200: todoSchema } },
      post: { summary: "Post a todo", responses: { 201: todoSchema } },
    },
  },
  { todo: todoSchema },
);

console.dir(result, { depth: null });

const expectResult = {
  openapi: "6.6.6",
  paths: {
    todos: {
      get: { $ref: "#/components/schemas/todo" },
      post: { $ref: "#/components/schemas/todo" },
    },
  },
  components: {
    schemas: {
      todo: {
        type: "object",
        properties: {
          id: { type: "string", format: "uuid" },
          content: { type: "string" },
          completed: { type: "boolean" },
        },
        required: ["id", "content"],
        additionalProperties: false,
      },
    },
  },
};

StefanTerdell avatar Jan 19 '24 18:01 StefanTerdell

I needed support for this also, published a quick implementation: https://www.npmjs.com/package/@devidw/trpc-openapi

Source-Code: https://github.com/devidw/trpc-openapi

All you need to do is pass a object with your model schemas when you generate the document:

import { generateOpenApiDocument } from "trpc-openapi";
import { z } from "zod";

const SomeSchema = z.object({
  a: z.literal(69),
});

generateOpenApiDocument(appRouter, {
  // ...
  defs: {
    SomeSchema,
  },
});

devidw avatar Jun 27 '24 20:06 devidw