rifont / zod-openapi

Use Zod Schemas to create OpenAPI v3.x documentation

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

zod-openapi logo

zod-openapi

A Typescript library to use Zod Schemas to create OpenAPI v3.x documentation


Install

Install via npm or yarn:

npm install zod zod-openapi
## or
yarn add zod zod-openapi

Usage

extendZodWithOpenApi

This mutates Zod to add an extra .openapi() method. Make a side-effectful import at the top of your entry point(s).

import { z } from 'zod';
import { extendZodWithOpenApi } from 'zod-openapi';

extendZodWithOpenApi(z);

z.string().openapi({ description: 'hello world!', example: 'hello world' });

.openapi()

Use the .openapi() method to add metadata to a specific Zod type. The .openapi() method takes an object with the following options:

Option Description
OpenAPI Options This will take any option you would put on a SchemaObject.
effectType Use to override the creation type for a Zod Effect
param Use to provide metadata for request parameters
header Use to provide metadata for response headers
ref Use this to auto register a schema
refType Use this to set the creation type for a component which is not referenced in the document.
type Use this to override the generated type. If this is provided no metadata will be generated.

createDocument

Creates an OpenAPI documentation object

import { z } from 'zod';
import { createDocument, extendZodWithOpenApi } from 'zod-openapi';

extendZodWithOpenApi(z);

const jobId = z.string().openapi({
  description: 'Job ID',
  example: '12345',
});

const title = z.string().openapi({
  description: 'Job title',
  example: 'My job',
});

const document = createDocument({
  openapi: '3.1.0',
  info: {
    title: 'My API',
    version: '1.0.0',
  },
  paths: {
    '/jobs/{jobId}': {
      put: {
        requestParams: { path: z.object({ jobId }) },
        requestBody: {
          content: {
            'application/json': { schema: z.object({ title }) },
          },
        },
        responses: {
          '200': {
            description: '200 OK',
            content: {
              'application/json': { schema: z.object({ jobId, title }) },
            },
          },
        },
      },
    },
  },
});

Generates the following object:

{
  "openapi": "3.1.0",
  "info": {
    "title": "My API",
    "version": "1.0.0"
  },
  "paths": {
    "/jobs/{jobId}": {
      "put": {
        "parameters": [
          {
            "in": "path",
            "name": "jobId",
            "schema": {
              "type": "string",
              "description": "Job ID",
              "example": "12345"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string",
                    "description": "Job title",
                    "example": "My job"
                  }
                },
                "required": ["title"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "200 OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobId": {
                      "type": "string",
                      "description": "Job ID",
                      "example": "12345"
                    },
                    "title": {
                      "type": "string",
                      "description": "Job title",
                      "example": "My job"
                    }
                  },
                  "required": ["jobId", "title"]
                }
              }
            }
          }
        }
      }
    }
  }
}

Request Parameters

Query, Path, Header & Cookie parameters can be created using the requestParams key under the method key as follows:

createDocument({
  paths: {
    '/jobs/:a': {
      put: {
        requestParams: {
          path: z.object({ a: z.string() }),
          query: z.object({ b: z.string() }),
          cookie: z.object({ cookie: z.string() }),
          header: z.object({ 'custom-header': z.string() }),
        },
      },
    },
  },
});

If you would like to declare parameters in a more traditional way you may also declare them using the parameters key. The definitions will then all be combined.

createDocument({
  paths: {
    '/jobs/:a': {
      put: {
        parameters: [
          z.string().openapi({
            param: {
              name: 'job-header',
              in: 'header',
            },
          }),
        ],
      },
    },
  },
});

Request Body

Where you would normally declare the media type, set the schema as your Zod Schema as follows.

createDocument({
  paths: {
    '/jobs': {
      get: {
        requestBody: {
          content: {
            'application/json': { schema: z.object({ a: z.string() }) },
          },
        },
      },
    },
  },
});

If you wish to use OpenAPI syntax for your schemas, simply add an OpenAPI schema to the schema field instead.

Responses

Similarly to the Request Body, simply set the schema as your Zod Schema as follows. You can set the response headers using the headers key.

createDocument({
  paths: {
    '/jobs': {
      get: {
        responses: {
          200: {
            description: '200 OK',
            content: {
              'application/json': { schema: z.object({ a: z.string() }) },
            },
            headers: z.object({
              'header-key': z.string(),
            }),
          },
        },
      },
    },
  },
});

Creating Components

OpenAPI allows you to define reusable components and this library allows you to replicate that in two separate ways.

  1. Auto registering schema
  2. Manually registering schema

Schema

If we take the example in createDocument and instead create title as follows

Auto Registering Schema
const title = z.string().openapi({
  description: 'Job title',
  example: 'My job',
  ref: 'jobTitle', // <- new field
});

Wherever title is used in schemas across the document, it will instead be created as a reference.

{ "$ref": "#/components/schemas/jobTitle" }

title will then be outputted as a schema within the components section of the documentation.

{
  "components": {
    "schemas": {
      "jobTitle": {
        "type": "string",
        "description": "Job title",
        "example": "My job"
      }
    }
  }
}

This can be an extremely powerful way to generate better Open API documentation. There are some Open API features like discriminator mapping which require all schemas in the union to contain a ref.

Manually Registering Schema

Another way to register schema instead of adding a ref is to add it to the components directly. This will still work in the same way as ref. So whenever we run into that Zod type we will replace it with a reference.

eg.

createDocument({
  components: {
    schemas: {
      jobTitle: title, // this will register this Zod Schema as jobTitle unless `ref` in `.openapi()` is specified on the type
    },
  },
});
Zod Effects

.transform() and .pipe() are complicated because they technically comprise of two types (input & output). This means that we need to understand which type you are creating. In particular with transform it is very difficult to infer the output type. This library will automatically select which type to use by checking how the schema is used based on the following rules:

Input: Request Bodies, Request Parameters, Headers

Output: Responses, Response Headers

If a registered schema with a transform or pipeline is used in both a request and response schema you will receive an error because the created schema for each will be different. To override the creation type for a specific ZodEffect, add an .openapi() field on it and set the effectType field to input or output. This will force this library to always generate the input/output type even if we are creating a response (output) or request (input) type. You typically want to use this when you know your transform has not changed the type.

.preprocess() will always return the output type even if we are creating an input schema. If a different input type is required you can achieve this with a .transform() combined with a .pipe() or simply declare a manual type in .openapi().

If you are adding a ZodSchema directly to the components section which is not referenced anywhere in the document, additional context may be required to create either an input or output schema. You can do this by setting the refType field to input or output in .openapi(). This defaults to output by default.

Parameters

Query, Path, Header & Cookie parameters can be similarly registered:

// Easy auto registration
const jobId = z.string().openapi({
  description: 'Job ID',
  example: '1234',
  param: { ref: 'jobRef' },
});

createDocument({
  paths: {
    '/jobs/{jobId}': {
      put: {
        requestParams: {
          header: z.object({
            jobId,
          }),
        },
      },
    },
  },
});

// or more verbose auto registration
const jobId = z.string().openapi({
  description: 'Job ID',
  example: '1234',
  param: { in: 'header', name: 'jobId', ref: 'jobRef' },
});

createDocument({
  paths: {
    '/jobs/{jobId}': {
      put: {
        parameters: [jobId],
      },
    },
  },
});

// or manual registeration
const otherJobId = z.string().openapi({
  description: 'Job ID',
  example: '1234',
  param: { in: 'header', name: 'jobId' },
});

createDocument({
  components: {
    parameters: {
      jobRef: jobId,
    },
  },
});

Response Headers

Response headers can be similarly registered:

const header = z.string().openapi({
  description: 'Job ID',
  example: '1234',
  header: { ref: 'some-header' },
});

// or

const jobIdHeader = z.string().openapi({
  description: 'Job ID',
  example: '1234',
});

createDocument({
  components: {
    headers: {
      someHeaderRef: jobIdHeader,
    },
  },
});

Responses

Entire Responses can also be registered

const response: ZodOpenApiResponseObject = {
  description: '200 OK',
  content: {
    'application/json': {
      schema: z.object({ a: z.string() }),
    },
  },
  ref: 'some-response',
};

//or

const response: ZodOpenApiResponseObject = {
  description: '200 OK',
  content: {
    'application/json': {
      schema: z.object({ a: z.string() }),
    },
  },
};

createDocument({
  components: {
    responses: {
      'some-response': response,
    },
  },
});

Supported OpenAPI Versions

Currently the following versions of OpenAPI are supported

  • 3.0.0
  • 3.0.1
  • 3.0.2
  • 3.0.3
  • 3.1.0

Setting the openapi field will change how the some of the components are rendered.

createDocument({
  openapi: '3.1.0',
});

For example in z.string().nullable() will be rendered differently

3.0.0

{
  "type": "string",
  "nullable": true
}

3.1.0

{
  "type": ["string", "null"]
}

Supported Zod Schema

  • ZodAny
  • ZodArray
    • minItems/maxItems mapping for .length(), .min(), .max()
  • ZodBoolean
  • ZodBranded
  • ZodCatch
  • ZodDate
    • string type mapping by default
  • ZodDefault
  • ZodDiscriminatedUnion
    • discriminator mapping when all schemas in the union contain a ref.
  • ZodEffects
    • transform support for request schemas. See Zod Effects for how to enable response schema support
    • pre-process support. We assume that the input type is the same as the output type. Otherwise pipe and transform can be used instead.
    • refine full support
  • ZodEnum
  • ZodLazy
    • The recursive schema within the ZodLazy or the ZodLazy must be registered as a component. See Creating Components for more information.
  • ZodLiteral
  • ZodNativeEnum
    • supporting string, number and combined enums.
  • ZodNull
  • ZodNullable
  • ZodNumber
    • integer type mapping for .int()
    • exclusiveMin/min/exclusiveMax/max mapping for .min(), .max(), lt(), gt()
  • ZodObject
    • additionalProperties mapping for .catchall(), .strict()
    • allOf mapping for .extend() when the base object is registered and does not have catchall(), strict() and extension does not override a field.
  • ZodOptional
  • ZodPipeline
  • ZodReadonly
  • ZodRecord
  • ZodSet
    • Treated as an array with uniqueItems (you may need to add a pre-process)
  • ZodString
    • format mapping for .url(), .uuid(), .email(), .datetime()
    • minLength/maxLength mapping for .length(), .min(), .max()
    • pattern mapping for .regex(), .startsWith(), .endsWith(), .includes()
  • ZodTuple
    • items mapping for .rest()
    • prefixItems mapping for OpenAPI 3.1.0+
  • ZodUnion
  • ZodUnknown

If this library cannot determine a type for a Zod Schema, it will throw an error. To avoid this, declare a manual type in the .openapi() section of that schema.

eg.

z.custom().openapi({ type: 'string' });

Examples

See the library in use in the examples folder.

Ecosystem

  • fastify-zod-openapi - Fastify plugin for zod-openapi. This includes type provider, Zod schema validation, Zod schema serialization and Swagger UI support.

  • eslint-plugin-zod-openapi - Eslint rules for zod-openapi. This includes features which can autogenerate Typescript comments for your Zod types based on your description, example and deprecated fields.

Credits

zod-openapi was created while trying to add a feature to support auto registering schemas. This proved to be extra challenging given the overall structure of the library so I decided re-write the whole thing. I was a big contributor to this library and love everything it's done, however I could not go past a few issues.

  1. The underlying structure of the library consists of tightly coupled classes which require you to create an awkward Registry class to create references. This would mean you would need to ship a registry class instance along with your types which makes sharing types difficult.

  2. No auto registering schema. Most users do not want to think about this so having to import and call .register() is a nuisance.

  3. When you register a schema using the registry you need to use the outputted type from the .register() call. You do not need to do such a thing with this library.

  4. No transform support or safety. You can use a type to override the transform type but what happens when that transform logic changes?

  5. No input/output validation with components. What happens when you register a component with a transform which technically comprises of two types in a request and a response?

Did I really rewrite an entire library just for this? Absolutely. I believe that creating documentation and types should be as simple and as frictionless as possible.

Migration

  1. Delete the OpenAPIRegistry and OpenAPIGenerator classes
  2. Replace any .register() call made and replace them with ref in .openapi() or alternatively, add them directly to the components section of the schema.
const registry = new OpenAPIRegistry();

const foo = registry.register(
  'foo',
  z.string().openapi({ description: 'foo' }),
);
const bar = z.object({ foo });

// Replace with:
const foo = z.string().openapi({ ref: 'foo', description: 'foo' });
const bar = z.object({ foo });

// or
const foo = z.string().openapi({ description: 'foo' });
const bar = z.object({ foo });

const document = createDocument({
  components: {
    schemas: {
      foo,
    },
  },
});
  1. Replace registry.registerComponent() with a regular OpenAPI component in the document.
const registry = new OpenAPIRegistry();

registry.registerComponent('securitySchemes', 'auth', {
  type: 'http',
  scheme: 'bearer',
  bearerFormat: 'JWT',
  description: 'An auth token issued by oauth',
});
// Replace with regular component declaration

const document = createDocument({
  components: {
    // declare directly in components
    securitySchemes: {
      auth: {
        type: 'http',
        scheme: 'bearer',
        bearerFormat: 'JWT',
        description: 'An auth token issued by oauth',
      },
    },
  },
});
  1. Replace registry.registerPath() with a regular OpenAPI paths in the document.
const registry = new OpenAPIRegistry();

registry.registerPath({
  method: 'get',
  path: '/foo',
  request: {
    query: z.object({ a: z.string() }),
    params: z.object({ b: z.string() }),
    body: z.object({ c: z.string() }),
    headers: z.object({ d: z.string() })
  },
  responses: {},
});
// Replace with regular path declaration

const getFoo: ZodOpenApiPathItemObject = {
  get: {
    requestParams: {
      query: z.object({ a: z.string() }),
      path: z.object({ b: z.string() }), // params -> path
      header: z.object({ c: z.string() }) // headers -> header
    }, // renamed from request -> requestParams
    requestBody: z.object({c: z.string() }) // request.body -> requestBody
    responses: {},
  },
};

const document = createDocument({
  paths: {
    '/foo': getFoo,
  },
});

Development

Prerequisites

  • Node.js LTS
  • Yarn 1.x
yarn
yarn build

Test

yarn test

Lint

# Fix issues
yarn format

# Check for issues
yarn lint

Release

To release a new version

  1. Create a new GitHub Release
  2. Select 🏷️ Choose a tag, enter a version number. eg. v1.2.0 and click + Create new tag: vX.X.X on publish.
  3. Click the Generate release notes button and adjust the description.
  4. Tick the Set as the latest release box and click Publish release. This will trigger the Release workflow.
  5. Check the Pull Requests tab for a PR labelled Release vX.X.X.
  6. Click Merge Pull Request on that Pull Request to update master with the new package version.

To release a new beta version

  1. Create a new GitHub Release
  2. Select 🏷️ Choose a tag, enter a version number with a -beta.X suffix eg. v1.2.0-beta.1 and click + Create new tag: vX.X.X-beta.X on publish.
  3. Click the Generate release notes button and adjust the description.
  4. Tick the Set as a pre-release box and click Publish release. This will trigger the Prerelease workflow.

About

Use Zod Schemas to create OpenAPI v3.x documentation

License:MIT License


Languages

Language:TypeScript 99.8%Language:JavaScript 0.2%