Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically replace known schemas with $refs #142

Closed
rattrayalex opened this issue May 18, 2023 · 4 comments
Closed

Automatically replace known schemas with $refs #142

rattrayalex opened this issue May 18, 2023 · 4 comments

Comments

@rattrayalex
Copy link

It'd be very helpful if this library could automatically replace zod schemas with their $refs once they are registered, at the time the document is generated.

This could perhaps be opt-in, perhaps with a generator.replaceRefs() or similar command.

Given this code:

const User = z.object({
  name: z.string(),
});
const Post = z.object({
  contents: z.string(),
  author: User,
});

const registry = new OpenAPIRegistry();
registry.register("User", User);
registry.register("Post", Post);

const generator = new OpenAPIGenerator(registry.definitions, "3.0.0");
const document = generator.generateDocument({
  info: {
    version: "1.0.0",
    title: "My API",
  },
  servers: [{ url: "v1" }],
});

Current output:

{
  components: {
    schemas: {
      User: {},
      Post: {
        type: 'object',
        properties: {
          contents: { type: 'string' },
          author: {
            type: 'object',
            properties: { name: { type: 'string' } },
            required: [ 'name' ]
          }
        },
        required: [ 'contents', 'author' ]
      }
    },
  },
}

Desired output:

{
  components: {
    schemas: {
      User: {},
      Post: {
        type: 'object',
        properties: {
          contents: { type: 'string' },
          author: { $ref: '#/components/schemas/User' },
        },
        required: [ 'contents', 'author' ]
      }
    },
  },
}

This should also enable recursive/cyclical schemas, which currently through an error: #140

Currently, we do this manually; if it helps, here's our replaceRefs code:

code ```ts export function replaceRefs( schema: z.ZodTypeAny, schemaRefs: Map ): T { const visited = new Set(); return recurse(schema) as T; function recurse(schema: z.ZodTypeAny): z.ZodTypeAny { const ref = schemaRefs.get(schema); if (ref) return ref; if (visited.has(schema)) throw new Error("circular references not supported yet"); visited.add(schema);
if (schema instanceof z.ZodArray) {
  return z.array(recurse(schema.element));
}
if (schema instanceof z.ZodTuple) {
  return z.tuple(schema.items.map(recurse));
}
if (schema instanceof z.ZodNullable) {
  return recurse(schema.unwrap()).nullable();
}
if (schema instanceof z.ZodOptional) {
  return recurse(schema.unwrap()).optional();
}
if (schema instanceof z.ZodDefault) {
  return recurse(schema._def.innerType).default(schema._def.defaultValue);
}
if (schema instanceof z.ZodObject) {
  return z.object(
    Object.fromEntries(
      Object.keys(schema.shape).map((key) => [
        key,
        recurse(schema.shape[key]),
      ])
    )
  );
}
if (schema instanceof z.ZodRecord) {
  return z.record(recurse(schema.keySchema), recurse(schema.valueSchema));
}
if (schema instanceof z.ZodUnion) {
  return z.union(schema.options.map(recurse));
}
if (schema instanceof z.ZodIntersection) {
  return z.intersection(
    recurse(schema._def.left),
    recurse(schema._def.right)
  );
}
if (schema instanceof z.ZodDiscriminatedUnion) {
  return z.discriminatedUnion(
    schema._def.discriminator,
    schema.options.map(recurse)
  );
}
if (schema instanceof z.ZodEffects) {
  return z.effect(recurse(schema._def.schema), schema._def.effect);
}
return schema;

}
}

</details>


And, in case it helps, here's what our usage code looks like:
<details>
<summary>code</summary>
```ts
function openapiSpec(models, endpoints): OpenAPIObject {
  const registry = new OpenAPIRegistry();

  const schemaRefs = new Map();
  for (const [name, model] of Object.entries(models)) {
    schemaRefs.set(model, registry.register(name, model));
  }

  for (const route of endpoints) {
    const { httpMethod, path } = route;
    registry.registerPath({
      method: httpMethod,
      path: path,
      description: "TODO",
      summary: "TODO",
      request: {
        params: route.path
          ? (replaceRefs(route.path, schemaRefs) as z.AnyZodObject)
          : undefined,
        query: route.query
          ? (replaceRefs(route.query, schemaRefs) as z.AnyZodObject)
          : undefined,
        body: route.body
          ? {
              content: {
                "application/json": {
                  schema: replaceRefs(route.body, schemaRefs),
                },
              },
            }
          : undefined,
      },
      responses: {
        200: {
          description: "TODO",
          content: route.response
            ? {
                "application/json": {
                  schema: replaceRefs(route.response, schemaRefs),
                },
              }
            : undefined,
        },
      },
    });
  }

  const generator = new OpenAPIGenerator(registry.definitions, "3.0.0");

  const document = generator.generateDocument({
    info: {
      version: "1.0.0",
      title: "My API",
    },
    servers: [{ url: "v1" }],
  });
  
  return document;
}
@AGalabov
Copy link
Collaborator

@rattrayalex can you take a look at #99. I think this is exactly what we've implemented there. However this is not yet released since we want to merge one more PR that has a breaking change

@jedwards1211
Copy link

jedwards1211 commented May 22, 2023

@AGalabov for our use case we don't want the ref id to live on metadata props attached to the zod schema, we want the registry to be the sole source of that information. So if we've called registry.register("User", User);, anywhere User is referenced by a zod schema (including z.lazy), a $ref would get generated.

For this to work on every schema the generator would have to call a method like registry.get$ref(schema), and if that returns a string it would be used as the $ref, otherwise it returns null and the generator goes on generating an inline schema.

So for example when the generator gets to the author: User field of Post, and is generating the value schema, it calls registry.getRefId(User) which would return #/components/schemas/User.

Basically we need to declare what goes in #/components/schemas in a separate file from where our zod schemas are declared. If you want to stick to using a refId property in internal metadata attached to the schema, we'll probably just fork this to do it the way we need.

@samchungy
Copy link
Contributor

samchungy commented May 23, 2023

we'll probably just fork this to do it the way we need.

Touche, that's what I did @jedwards1211 🤷 I added what you were after here: https://github.com/samchungy/zod-openapi#manually-registering-schema

https://github.com/samchungy/zod-openapi

@rattrayalex
Copy link
Author

Hey @AGalabov , congrats on the v5.0 release! Excited to try it out!

I saw in the other thread your comment:

Overall the idea was that wherever you use that schema you'd want it to have its reference to be reused => I don't think we would be changing that, sorry.

Would you be open to a PR implementing this, similar to the functionality Sam has in zod-openapi? Otherwise I think we may just give his project a try…

@asteasolutions asteasolutions locked and limited conversation to collaborators Nov 16, 2023
@AGalabov AGalabov converted this issue into discussion #190 Nov 16, 2023

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants