asteasolutions / zod-to-openapi

A library that generates OpenAPI (Swagger) docs from Zod schemas
MIT License
955 stars 60 forks source link

`z.union` does not work in request params and request query #218

Closed fazzabi closed 7 months ago

fazzabi commented 7 months ago

Hi,

Firstly, I want to express my gratitude for the valuable package you've provided.

Recently, I encountered an issue with zod-to-openapi. It seems that it fails to handle query or params that begin with the z.union while it performs smoothly with body.

Here's a snippet of the schema causing the problem:

const schema = z.union([z.object({ type: z.string() }), z.object({ age: z.number() })])

and the path:

registry.registerPath({
  method: 'post',
  path: '/pets',
  description: 'test',
  request: {
    query: schema, // <-- error
    params: schema, // <-- error
    body: {
      content: {
        'application/json': {
          schema
        },
      }
    },
  },
  responses: {},
});

The error message received is:

MissingParameterDataError {
  message: 'Missing parameter data, please specify `name` and other OpenAPI parameter props using the `param` field of `ZodSchema.openapi`',
  data: { missingField: 'name', location: 'query', route: 'post /pets' }
}

I tried to add .openapi('name') to the schema and it change nothing. thanks in advance!

AGalabov commented 7 months ago

Hey @fazzabi thank you for the kind words.

There is actually a very similar issue that is open so I will reuse my comment - https://github.com/asteasolutions/zod-to-openapi/issues/198#issuecomment-1874090516

The key part is this:

Overall the idea that we've followed is that query, params and headers are not just any type - they are always an object (or even a key-value entries) because of how HTTP specifications are being followed. And in the OpenAPI realm they are described in a different manner than the body. We chose to use the AnyZodObject instead of a regular Record type from typescript mainly for easier usage. The rest of the types (like just a number) are not compatible with the OpenAPI specification.

In your scenario - I am not sure what we could possibly do to handle a union. In practice I imagine that for your specific scenario the correct documentation is an object with 2 optional properties. However depending on the union - that might not be the case, so I am not sure if we should go down this path. cc: @georgyangelov for a second opinion.

What I can do is to try and help you out with the current @asteasolutions/zod-to-openapi mechanisms:

const baseSchema = zobject({ type: z.ostring(), age: z.onumber() }) // used for documentation

// Depending on your usecase this could even be:
// !!data.age || !!data.type
const schema = baseSchema.refine(data => data.age !== undefined || data.type !== undefined); // used for further validation

Note: There is a possibility to try and handle refine on objects in request params/query - which would further simplify the example above - for that you can follow the other issue

Does that work for you? And does the argumentation above make sense as to why we would rather not delve into handling unions out of the box?

fazzabi commented 7 months ago

Thank you for your quick response, yes as you can see one of the use cases is to have one of the two fields which is mandatory, it can also be to have a different schema depending on the value of a field.

I think using union would be better, but refine is also okay. The strange thing is, even with refine, it's not working as expected. Right now, I'm migrating projects that use Hapi.js, where we used Joi, to Zod. As part of this transition, I'm developing a plugin to replace what hapi-swagger does with zod-to-openapi. This plugin goes through all the route configurations and generates an OpenAPI file using the same setup used for input validation. Since it uses the same config I don't see how I will remove the refine before passing it to zod-to-openapi.

AGalabov commented 7 months ago

@fazzabi after further consideration we decided that handling union of objects is not something that we want to pursue. In general OpenAPI parameters are "a single schema" and as such there is no way to correctly represent a zod tuple into such OpenAPI schema, that would be backwards convertible into a zod schema (and this is generally what we try to achieve).

Any sort of assumptions of combining the underlying items of the tuple would be somewhat hacky and they would never be fully correct.

However as I mentioned - we did add the support for z.object({}).refine and z.object({}).transform as part of the v7.0.0 Release :rocket: .

In your case I think the best solution would be to do:

const baseSchema = z.object({ type: z.ostring(), age: z.onumber() }); // used for documentation
const validationSchema = z.union([
  z.object({ type: z.string() }),
  z.object({ age: z.number() }),
]); // used for further validation

export const schema = baseSchema.transform(data => validationSchema.parse(data)); // the final exported schema

I know it is not ideal but I imagine it gets the job done, it handles both scenarios - valid documentation (controlled by our library with your manual interference) and a complete validation and type safety.