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

How to document a field, but not change the top level schema type?

Open marceloverdijk opened this issue 1 year ago • 8 comments

When I have a schema like:

export const CircuitTypeSchema = z
  .enum([
    'RACE',
    'ROAD',
    'STREET'
  ])
  .openapi('CircuitType', { description: 'Represents a circuit type.' });

export const CircuitSchema = z
  .object({
    id: z.string().openapi({ description: 'The unique identifier.', example: 'melbourne' }),
    name: z.string().openapi({ description: 'The name.', example: 'Melbourne' }),
    type: CircuitTypeSchema, // .openapi({ description: 'The circuit type.', example: 'STREET' }),
  })
  .openapi('Circuit', { description: 'Represents a circuit.' });

this will generate a openapi spec like:

"components":{
  "schemas":{
    "CircuitType":{
      "type":"string",
      "enum":[
        "RACE",
        "ROAD",
        "STREET"
      ],
      "description":"Represents a circuit type."
    },
    "Circuit":{
      "type":"object",
      "properties":{
        "id":{
          "type":"string",
          "description":"The unique identifier.",
          "example":"melbourne"
        },
        "name":{
          "type":"string",
          "description":"The name.",
          "example":"Melbourne"
        },
        "type":{
          "$ref":"#/components/schemas/CircuitType"
        },
      },
      "required":[
        "id",
        "name",
        "type"      ],
      "description":"Represents a circuit."
    }
  },

which is good except, that I explicitly want to set the description and example for the Circuit.type field (which is now not specified.

So I tried with:

type: CircuitTypeSchema.openapi({ description: 'The circuit type.', example: 'STREET' }),

which unfortunately does not change the Circuit.type but the top-level CircuitType schema only:

"CircuitType": {
  "type": "string",
  "enum": [
    "RACE",
    "ROAD",
    "STREET"
  ],
  "description": "The circuit type.", <== Should be 'Represents the circuit type.'
  "example": "STREET" <== I don't want an example here.
},

and the Circuit.type:

    "Circuit":{
      "type":"object",
      "properties":{
        ..
        "type":{
          "$ref":"#/components/schemas/CircuitType"
        },

Is there a way to document (openapi) the Circuit.type field, bit not affect the top-level schema type?

marceloverdijk avatar Jun 28 '24 20:06 marceloverdijk

So just to elaborate, I want to setup the schema so this is generated into the openapi spec file:

"components": {
  "schemas": {
    "CircuitType": {
      "type": "string",
      "enum": [
        "RACE",
        "ROAD",
        "STREET"
      ],
      "description": "Represents a circuit type."
    },
    "Circuit": {
      "type": "object",
      "properties": {
        "id": {
          "type": "string",
          "description": "The unique identifier.",
          "example": "melbourne"
        },
        "name": {
          "type": "string",
          "description": "The name.",
          "example": "Melbourne"
        },
        "type": {
          "$ref": "#/components/schemas/CircuitType",
          "description": "The circuit type.",
          "example": "STREET"
        },
        ..
      },
      "required": [..],
      "description": "Represents a circuit."
    },
    ..

marceloverdijk avatar Jun 29 '24 18:06 marceloverdijk

@marceloverdijk thank you for bringing this up. However I am unable to reproduce. Locally what I test with is:

import { z } from 'zod';
import { extendZodWithOpenApi } from './zod-extensions';
import { OpenApiGeneratorV3 } from './v3.0/openapi-generator';

extendZodWithOpenApi(z);

export const CircuitTypeSchema = z
  .enum(['RACE', 'ROAD', 'STREET'])
  .openapi('CircuitType', { description: 'Represents a circuit type.' });

export const CircuitSchema = z
  .object({
    id: z
      .string()
      .openapi({ description: 'The unique identifier.', example: 'melbourne' }),
    name: z
      .string()
      .openapi({ description: 'The name.', example: 'Melbourne' }),
    type: CircuitTypeSchema.openapi({
      description: 'The circuit type.',
      example: 'STREET',
    }),
  })
  .openapi('Circuit', { description: 'Represents a circuit.' });

const generator = new OpenApiGeneratorV3([CircuitTypeSchema, CircuitSchema]);
const doc = generator.generateDocument({} as never);

console.log(JSON.stringify(doc, null, 4));

And the resulting JSON looks like this:

{
    "components": {
        "schemas": {
            "CircuitType": {
                "type": "string",
                "enum": [
                    "RACE",
                    "ROAD",
                    "STREET"
                ],
                "description": "Represents a circuit type."
            },
            "Circuit": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "string",
                        "description": "The unique identifier.",
                        "example": "melbourne"
                    },
                    "name": {
                        "type": "string",
                        "description": "The name.",
                        "example": "Melbourne"
                    },
                    "type": {
                        "allOf": [
                            {
                                "$ref": "#/components/schemas/CircuitType"
                            },
                            {
                                "description": "The circuit type.",
                                "example": "STREET"
                            }
                        ]
                    }
                },
                "required": [
                    "id",
                    "name",
                    "type"
                ],
                "description": "Represents a circuit."
            }
        },
        "parameters": {}
    },
    "paths": {}
}

We've implemented the allOf logic since what you suggest:

 "type": {
          "$ref": "#/components/schemas/CircuitType",
          "description": "The circuit type.",
          "example": "STREET"
        },

is not valid according to the OpenAPI specification.

Can you please double check your example

AGalabov avatar Jul 02 '24 10:07 AGalabov

Interesting and thx for your feedback.

In my case it overwrites the description and add the example to the CircuitType schema definition and for the type field in the Circuit schema it does not use anyOf but just the "type": { "$ref": "#/components/schemas/CircuitType" }.

I checked my project and it uses the latest 7.1.1:

"node_modules/@asteasolutions/zod-to-openapi": {
      "version": "7.1.1",
      "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.1.1.tgz",

but my setup is different. I'm targeting openapi spec 3.1 and I'm using Cloudflare Chanfana. Maybe that's causing some impact as Chanfana is using zod/openapi as well (https://github.com/asteasolutions/zod-to-openapi/issues/234#issuecomment-2198022860)

marceloverdijk avatar Jul 02 '24 11:07 marceloverdijk

I'm also facing another (blocking) issue now (https://github.com/asteasolutions/zod-to-openapi/issues/234) so I will create a minimal reproducible project that I can share which will showcase both issues.

marceloverdijk avatar Jul 04 '24 20:07 marceloverdijk

Hello, this is also happening to us, where we reuse a zod object schema with an existing description, re-call .openapi(), and add a new description when it is being used in another schema property. The the last description is the description used to all references to the schema.

bombillazo avatar Jun 13 '25 06:06 bombillazo

@marceloverdijk so after playing around, the only way I got it to work was explicitly calling register('Entity', schema):


api.openAPIRegistry.register('Identifier', IdentifierSchema);

And I could use my schema inside another overriding the description. However, this is only a partial solution since if the original schema has a property set that you don't want in your other Schema, the override will not eliminate it and instead inherit it.

The true solution (which kinda is a hassle and i really don't like) is to extract the schema sans .openapi() and assign the openapi call independently for each use case. It is more verbose and needs setup but it works as expected

bombillazo avatar Jun 13 '25 06:06 bombillazo

I have a similar issue, not sure if it's the same:

// ...
request: {
    headers: z.object({
        'X-Caller-ID': z.string().optional().openapi({
            description: 'The platform (iOS/Android) of the requester',
            example: 'NS App Android',
        }),
    }),
}

generates:

 "parameters": [
  {
    "schema": {
      "type": "string",
      "description": "The platform (iOS/Android) of the requester",
      "example": "NS App Android"
    },
    "required": false,
    "name": "X-Caller-ID",
    "in": "header"
  }
]

However, I want the description field to be part of the parameter, not the schema object. They have subtle but important differences and are also used differently by tools like SwaggerUI. Same applies to example.

Let me know if I should create a separate issue for this.

dirkluijk avatar Jun 25 '25 14:06 dirkluijk

@dirkluijk put description inside param:

{
  param: {
    description: "..."
  }
} 

bombillazo avatar Jun 25 '25 17:06 bombillazo