asteasolutions / zod-to-openapi

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

zod.custom(), zod.instanceof() expects required but is optional #271

Open tsutoringo opened 5 days ago

tsutoringo commented 5 days ago

Reproduce code

import { OpenAPIRegistry, OpenApiGeneratorV3, extendZodWithOpenApi } from 'npm:@asteasolutions/zod-to-openapi';
import { z } from 'npm:zod';

extendZodWithOpenApi(z);

const registry = new OpenAPIRegistry();

const NewFile = z
  .object({
    description: z.string().openapi({}),
    file_1: z.custom((target) => target instanceof File).openapi({
      type: 'string',
      format: 'binary'
    }),
    file_2: z.instanceof(File).openapi({
      type: 'string',
      format: 'binary'
    }),
  })
  .openapi('NewFile');

registry.registerPath({
  method: 'post',
  path: '/',
  request: {
    body: {
      content: {
        "multipart/form-data": {
          schema: NewFile
        }
      }
    }
  },
  responses: {
    200: {
      description: 'Success.',
    },
  }
});

const generator = new OpenApiGeneratorV3(registry.definitions);

const docs = generator.generateDocument({
  openapi: '3.0.0',
  info: {
    version: '1.0.0',
    title: 'My API',
    description: 'This is the API',
  },
  servers: [{ url: 'v1' }],
});

console.log(JSON.stringify(docs, null, '  '));

result

{
  "openapi": "3.0.0",
  "info": {
    "version": "1.0.0",
    "title": "My API",
    "description": "This is the API"
  },
  "servers": [
    {
      "url": "v1"
    }
  ],
  "components": {
    "schemas": {
      "NewFile": {
        "type": "object",
        "properties": {
          "description": {
            "type": "string"
          },
          "file_1": {
            "type": "string",
            "format": "binary"
          },
          "file_2": {
            "type": "string",
            "format": "binary"
          }
        },
        "required": [
          // expects `"description", "file_1", "file_2"`
          "description"
        ]
      }
    },
    "parameters": {}
  },
  "paths": {
    "/": {
      "post": {
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NewFile"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success."
          }
        }
      }
    }
  }
}

Actually file_1 and file_2 are required, so this code returns an error

const result = NewFile.safeParse({
  description: 'description',
  file_1: new File([], 'file_1'),
  // file_2: new File([], 'file_2')
});

console.log(result); // { success: false, error: ... }

I've looked into it myself, and the cause is probably that zod.custom and zod.instanceof are wrapped in zod.any with ZodEffect, and I think this code is doing something bad. https://github.com/asteasolutions/zod-to-openapi/blob/81f003daa97c3e4baf06adf2e37183e575317c41/src/openapi-generator.ts#L326-L327