├── .github └── workflows │ ├── check.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── example ├── index.ts └── openapi-docs.yml ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── spec ├── custom-components.spec.ts ├── enums.spec.ts ├── lib │ └── helpers.ts ├── lodash.spec.ts ├── metadata-overrides.spec.ts ├── modifiers │ ├── branded.spec.ts │ ├── catchall.spec.ts │ ├── custom.spec.ts │ ├── deepPartial.spec.ts │ ├── default.spec.ts │ ├── describe.spec.ts │ ├── instanceof.spec.ts │ ├── nullable.spec.ts │ ├── optional.spec.ts │ ├── pipe.spec.ts │ ├── preprocess.spec.ts │ ├── readonly.spec.ts │ ├── refine.spec.ts │ └── transform.spec.ts ├── openapi-metadata.spec.ts ├── registry.spec.ts ├── routes │ ├── index.spec.ts │ └── parameters.spec.ts ├── separate-zod-instance.spec.ts ├── setup-tests.ts └── types │ ├── any.spec.ts │ ├── array.spec.ts │ ├── bigint.spec.ts │ ├── date.spec.ts │ ├── discriminated-union.spec.ts │ ├── enum.spec.ts │ ├── intersection.spec.ts │ ├── native-enum.spec.ts │ ├── null.spec.ts │ ├── number.spec.ts │ ├── object-polymorphism.spec.ts │ ├── object.spec.ts │ ├── record.spec.ts │ ├── string.spec.ts │ ├── tuple.spec.ts │ ├── union.spec.ts │ └── unknown.spec.ts ├── src ├── errors.ts ├── index.ts ├── lib │ ├── enum-info.ts │ ├── lodash.ts │ ├── object-set.ts │ └── zod-is-type.ts ├── metadata.ts ├── openapi-generator.ts ├── openapi-metadata.ts ├── openapi-registry.ts ├── transformers │ ├── array.ts │ ├── big-int.ts │ ├── discriminated-union.ts │ ├── enum.ts │ ├── index.ts │ ├── intersection.ts │ ├── literal.ts │ ├── native-enum.ts │ ├── number.ts │ ├── object.ts │ ├── record.ts │ ├── string.ts │ ├── tuple.ts │ └── union.ts ├── types.ts ├── v3.0 │ ├── openapi-generator.ts │ └── specifics.ts ├── v3.1 │ ├── openapi-generator.ts │ └── specifics.ts └── zod-extensions.ts └── tsconfig.json /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: 8 | - '*' 9 | 10 | jobs: 11 | build-and-test: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | node-version: [16.x, 18.x, 20.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | 27 | - run: npm ci 28 | - run: npm run build 29 | - run: npm run lint 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-to-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 16 15 | cache: 'npm' 16 | registry-url: 'https://registry.npmjs.org' 17 | 18 | - run: npm ci 19 | - run: npm test 20 | 21 | - run: | 22 | git config --global user.name 'GitHub Actions' 23 | git config --global user.email 'github-actions@localhost' 24 | 25 | # Update the version in the package files 26 | - run: | 27 | GIT_TAG="${{github.event.release.tag_name}}" 28 | NEW_VERSION="${GIT_TAG/v/}" 29 | 30 | npm version "$NEW_VERSION" --allow-same-version --git-tag-version=false 31 | git add package* && git commit -m "Release $NEW_VERSION" 32 | 33 | - run: npm publish --access=public 34 | env: 35 | NODE_AUTH_TOKEN: ${{secrets.NPM_PUBLISH_TOKEN}} 36 | 37 | - run: git show 38 | - run: | 39 | git fetch origin master 40 | 41 | TAG_NAME="${{github.event.release.tag_name}}" 42 | LAST_COMMIT_ID="$(git rev-parse $TAG_NAME)" 43 | MASTER_COMMIT_ID="$(git rev-parse origin/master)" 44 | 45 | if [ "$LAST_COMMIT_ID" = "$MASTER_COMMIT_ID" ]; then 46 | git push origin HEAD:master 47 | else 48 | echo "Not pushing to master because the tag we're operating on is behind" 49 | fi 50 | 51 | # See https://stackoverflow.com/a/24849501 52 | - run: | 53 | git tag -f "${{github.event.release.tag_name}}" HEAD 54 | git push -f origin "refs/tags/${{github.event.release.tag_name}}" 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | .node-version 4 | .nvm 5 | .vscode 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | README.md 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Astea Solutions 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OpenApiGeneratorV3, 3 | // The exact same can be achieved by importing OpenApiGeneratorV31 instead: 4 | // OpenApiGeneratorV31 5 | OpenAPIRegistry, 6 | extendZodWithOpenApi, 7 | } from '../src'; 8 | import { z } from 'zod'; 9 | import * as yaml from 'yaml'; 10 | import * as fs from 'fs'; 11 | 12 | extendZodWithOpenApi(z); 13 | 14 | const registry = new OpenAPIRegistry(); 15 | 16 | const UserIdSchema = registry.registerParameter( 17 | 'UserId', 18 | z.string().openapi({ 19 | param: { 20 | name: 'id', 21 | in: 'path', 22 | }, 23 | example: '1212121', 24 | }) 25 | ); 26 | const UserSchema = z 27 | .object({ 28 | id: z.string().openapi({ 29 | example: '1212121', 30 | }), 31 | name: z.string().openapi({ 32 | example: 'John Doe', 33 | }), 34 | age: z.number().openapi({ 35 | example: 42, 36 | }), 37 | }) 38 | .openapi('User'); 39 | 40 | const bearerAuth = registry.registerComponent('securitySchemes', 'bearerAuth', { 41 | type: 'http', 42 | scheme: 'bearer', 43 | bearerFormat: 'JWT', 44 | }); 45 | 46 | registry.registerPath({ 47 | method: 'get', 48 | path: '/users/{id}', 49 | description: 'Get user data by its id', 50 | summary: 'Get a single user', 51 | security: [{ [bearerAuth.name]: [] }], 52 | request: { 53 | params: z.object({ id: UserIdSchema }), 54 | }, 55 | responses: { 56 | 200: { 57 | description: 'Object with user data.', 58 | content: { 59 | 'application/json': { 60 | schema: UserSchema, 61 | }, 62 | }, 63 | }, 64 | 204: { 65 | description: 'No content - successful operation', 66 | }, 67 | }, 68 | }); 69 | 70 | function getOpenApiDocumentation() { 71 | const generator = new OpenApiGeneratorV3(registry.definitions); 72 | 73 | return generator.generateDocument({ 74 | openapi: '3.0.0', 75 | info: { 76 | version: '1.0.0', 77 | title: 'My API', 78 | description: 'This is the API', 79 | }, 80 | servers: [{ url: 'v1' }], 81 | }); 82 | } 83 | 84 | function writeDocumentation() { 85 | // OpenAPI JSON 86 | const docs = getOpenApiDocumentation(); 87 | 88 | // YAML equivalent 89 | const fileContent = yaml.stringify(docs); 90 | 91 | fs.writeFileSync(`${__dirname}/openapi-docs.yml`, fileContent, { 92 | encoding: 'utf-8', 93 | }); 94 | } 95 | 96 | writeDocumentation(); 97 | -------------------------------------------------------------------------------- /example/openapi-docs.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | version: 1.0.0 4 | title: My API 5 | description: This is the API 6 | servers: 7 | - url: v1 8 | components: 9 | securitySchemes: 10 | bearerAuth: 11 | type: http 12 | scheme: bearer 13 | bearerFormat: JWT 14 | schemas: 15 | UserId: 16 | type: string 17 | example: '1212121' 18 | User: 19 | type: object 20 | properties: 21 | id: 22 | type: string 23 | example: '1212121' 24 | name: 25 | type: string 26 | example: John Doe 27 | age: 28 | type: number 29 | example: 42 30 | required: 31 | - id 32 | - name 33 | - age 34 | parameters: 35 | UserId: 36 | schema: 37 | $ref: '#/components/schemas/UserId' 38 | required: true 39 | name: id 40 | in: path 41 | paths: 42 | '/users/{id}': 43 | get: 44 | description: Get user data by its id 45 | summary: Get a single user 46 | security: 47 | - bearerAuth: [] 48 | parameters: 49 | - $ref: '#/components/parameters/UserId' 50 | responses: 51 | '200': 52 | description: Object with user data. 53 | content: 54 | application/json: 55 | schema: 56 | $ref: '#/components/schemas/User' 57 | '204': 58 | description: No content - successful operation 59 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | 5 | setupFilesAfterEnv: ['/spec/setup-tests.ts'], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@asteasolutions/zod-to-openapi", 3 | "version": "7.3.2", 4 | "description": "Builds OpenAPI schemas from Zod schemas", 5 | "main": "dist/index.cjs", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist", 10 | "package.json", 11 | "LICENSE", 12 | "README.md" 13 | ], 14 | "keywords": [ 15 | "typescript", 16 | "schema", 17 | "type", 18 | "openapi", 19 | "zod" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/asteasolutions/zod-to-openapi" 24 | }, 25 | "homepage": "https://github.com/asteasolutions/zod-to-openapi", 26 | "scripts": { 27 | "build": "rollup -c", 28 | "prepare": "npm run build", 29 | "test": "jest", 30 | "prettier": "prettier --write .", 31 | "lint": "prettier --check .", 32 | "prepublishOnly": "npm run build" 33 | }, 34 | "dependencies": { 35 | "openapi3-ts": "^4.1.2" 36 | }, 37 | "peerDependencies": { 38 | "zod": "^3.20.2" 39 | }, 40 | "devDependencies": { 41 | "@rollup/plugin-commonjs": "^25.0.7", 42 | "@rollup/plugin-node-resolve": "^15.2.3", 43 | "@types/jest": "^29.2.5", 44 | "jest": "^29.3.1", 45 | "prettier": "^2.7.1", 46 | "rollup": "^4.13.2", 47 | "rollup-plugin-typescript2": "^0.36.0", 48 | "ts-jest": "^29.0.3", 49 | "typescript": "^5.2.2", 50 | "yaml": "^2.2.2", 51 | "zod": "^3.22.0" 52 | }, 53 | "author": "Astea Solutions ", 54 | "license": "MIT" 55 | } 56 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | export default { 6 | input: 'src/index.ts', 7 | plugins: [ 8 | resolve(), 9 | commonjs(), 10 | typescript({ 11 | // Override the module compiler option to ESNext 12 | tsconfigOverride: { 13 | include: ['src/**/*'], 14 | compilerOptions: { 15 | module: 'ESNext', 16 | moduleResolution: 'node', 17 | emitDeclarationOnly: false, 18 | }, 19 | }, 20 | }), 21 | ], 22 | output: [ 23 | { file: 'dist/index.cjs', format: 'cjs' }, 24 | { file: 'dist/index.mjs', format: 'es' }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /spec/custom-components.spec.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIRegistry } from '../src/openapi-registry'; 2 | import { z } from 'zod'; 3 | import { extendZodWithOpenApi } from '../src/zod-extensions'; 4 | import { OpenApiGeneratorV3 } from '../src/v3.0/openapi-generator'; 5 | import { testDocConfig } from './lib/helpers'; 6 | 7 | extendZodWithOpenApi(z); 8 | 9 | // TODO: Tests with both generators 10 | describe('Custom components', () => { 11 | it('can register and generate security schemes', () => { 12 | const registry = new OpenAPIRegistry(); 13 | 14 | const bearerAuth = registry.registerComponent( 15 | 'securitySchemes', 16 | 'bearerAuth', 17 | { 18 | type: 'http', 19 | scheme: 'bearer', 20 | bearerFormat: 'JWT', 21 | } 22 | ); 23 | 24 | registry.registerPath({ 25 | path: '/units', 26 | method: 'get', 27 | security: [{ [bearerAuth.name]: [] }], 28 | responses: { 29 | 200: { 30 | description: 'Sample response', 31 | content: { 32 | 'application/json': { 33 | schema: z.string(), 34 | }, 35 | }, 36 | }, 37 | }, 38 | }); 39 | 40 | const builder = new OpenApiGeneratorV3(registry.definitions); 41 | const document = builder.generateDocument(testDocConfig); 42 | 43 | expect(document.paths['/units']?.get?.security).toEqual([ 44 | { bearerAuth: [] }, 45 | ]); 46 | 47 | expect(document.components!.securitySchemes).toEqual({ 48 | bearerAuth: { 49 | bearerFormat: 'JWT', 50 | scheme: 'bearer', 51 | type: 'http', 52 | }, 53 | }); 54 | }); 55 | 56 | it('can register and generate headers', () => { 57 | const registry = new OpenAPIRegistry(); 58 | 59 | const apiKeyHeader = registry.registerComponent('headers', 'api-key', { 60 | example: '1234', 61 | required: true, 62 | description: 'The API Key you were given in the developer portal', 63 | }); 64 | 65 | registry.registerPath({ 66 | path: '/units', 67 | method: 'get', 68 | responses: { 69 | 200: { 70 | description: 'Sample response', 71 | headers: { 'x-api-key': apiKeyHeader.ref }, 72 | content: { 73 | 'application/json': { 74 | schema: z.string(), 75 | }, 76 | }, 77 | }, 78 | }, 79 | }); 80 | 81 | const builder = new OpenApiGeneratorV3(registry.definitions); 82 | const document = builder.generateDocument(testDocConfig); 83 | 84 | expect(document.paths['/units']?.get?.responses['200'].headers).toEqual({ 85 | 'x-api-key': { $ref: '#/components/headers/api-key' }, 86 | }); 87 | 88 | expect(document.components!.headers).toEqual({ 89 | 'api-key': { 90 | example: '1234', 91 | required: true, 92 | description: 'The API Key you were given in the developer portal', 93 | }, 94 | }); 95 | }); 96 | 97 | it('can generate responses', () => { 98 | const registry = new OpenAPIRegistry(); 99 | 100 | const response = registry.registerComponent('responses', 'BadRequest', { 101 | description: 'BadRequest', 102 | content: { 103 | 'application/json': { 104 | schema: { 105 | type: 'object', 106 | properties: { name: { type: 'string' } }, 107 | }, 108 | }, 109 | }, 110 | }); 111 | 112 | registry.registerPath({ 113 | summary: 'Get user of an organization', 114 | method: 'get', 115 | path: '/test', 116 | responses: { 117 | '400': response.ref, 118 | }, 119 | }); 120 | 121 | const builder = new OpenApiGeneratorV3(registry.definitions); 122 | const document = builder.generateDocument(testDocConfig); 123 | 124 | expect(document.paths['/test']?.get?.responses['400']).toEqual({ 125 | $ref: '#/components/responses/BadRequest', 126 | }); 127 | 128 | expect(document.components!.responses).toEqual({ 129 | BadRequest: { 130 | description: 'BadRequest', 131 | content: { 132 | 'application/json': { 133 | schema: { 134 | type: 'object', 135 | properties: { name: { type: 'string' } }, 136 | }, 137 | }, 138 | }, 139 | }, 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /spec/enums.spec.ts: -------------------------------------------------------------------------------- 1 | import { enumInfo } from '../src/lib/enum-info'; 2 | 3 | describe('Enums', () => { 4 | describe('enumInfo', () => { 5 | it('can extract enum values from string enum', () => { 6 | enum Test { 7 | A = 'test-1', 8 | B = 'test-2', 9 | } 10 | 11 | const { values, type } = enumInfo(Test); 12 | 13 | expect(values).toEqual(['test-1', 'test-2']); 14 | expect(type).toEqual('string'); 15 | }); 16 | 17 | it('can extract enum values from numeric enum', () => { 18 | enum Test { 19 | A = 31, 20 | B = 42, 21 | } 22 | 23 | const { values, type } = enumInfo(Test); 24 | 25 | expect(values).toEqual([31, 42]); 26 | expect(type).toEqual('numeric'); 27 | }); 28 | 29 | it('can extract enum values from auto-incremented enum', () => { 30 | enum Test { 31 | A, 32 | B, 33 | } 34 | 35 | const { values, type } = enumInfo(Test); 36 | 37 | expect(values).toEqual([0, 1]); 38 | expect(type).toEqual('numeric'); 39 | }); 40 | 41 | it('can extract enum values from mixed enums', () => { 42 | enum Test { 43 | A = 42, 44 | B = 'test', 45 | } 46 | 47 | const { values, type } = enumInfo(Test); 48 | 49 | expect(values).toEqual([42, 'test']); 50 | expect(type).toEqual('mixed'); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ComponentsObject, 3 | OperationObject, 4 | SchemasObject as SchemasObjectV30, 5 | } from 'openapi3-ts/oas30'; 6 | import type { SchemasObject as SchemasObjectV31 } from 'openapi3-ts/oas31'; 7 | import type { ZodTypeAny } from 'zod'; 8 | import { 9 | OpenAPIDefinitions, 10 | OpenAPIRegistry, 11 | RouteConfig, 12 | } from '../../src/openapi-registry'; 13 | import { 14 | OpenApiGeneratorV3, 15 | OpenAPIObjectConfig, 16 | } from '../../src/v3.0/openapi-generator'; 17 | import { OpenApiGeneratorV31 } from '../../src/v3.1/openapi-generator'; 18 | import { OpenApiVersion } from '../../src/openapi-generator'; 19 | 20 | export function createSchemas( 21 | zodSchemas: ZodTypeAny[], 22 | openApiVersion: OpenApiVersion = '3.0.0' 23 | ) { 24 | const definitions = zodSchemas.map(schema => ({ 25 | type: 'schema' as const, 26 | schema, 27 | })); 28 | 29 | const OpenApiGenerator = 30 | openApiVersion === '3.1.0' ? OpenApiGeneratorV31 : OpenApiGeneratorV3; 31 | 32 | const { components } = new OpenApiGenerator(definitions).generateComponents(); 33 | 34 | return components; 35 | } 36 | 37 | export function expectSchema( 38 | zodSchemas: ZodTypeAny[], 39 | openAPISchemas: T extends '3.1.0' ? SchemasObjectV31 : SchemasObjectV30, 40 | openApiVersion?: T 41 | ) { 42 | const components = createSchemas(zodSchemas, openApiVersion); 43 | 44 | expect(components?.['schemas']).toEqual(openAPISchemas); 45 | } 46 | 47 | export function registerParameter( 48 | refId: string, 49 | zodSchema: T 50 | ) { 51 | const registry = new OpenAPIRegistry(); 52 | 53 | const schema = registry.registerParameter(refId, zodSchema); 54 | 55 | return { type: 'parameter', schema } as const; 56 | } 57 | 58 | export function createTestRoute(props: Partial = {}): RouteConfig { 59 | return { 60 | method: 'get', 61 | path: '/', 62 | responses: { 63 | 200: { 64 | description: 'OK Response', 65 | }, 66 | }, 67 | ...props, 68 | }; 69 | } 70 | 71 | export const testDocConfig: OpenAPIObjectConfig = { 72 | openapi: '3.0.0', 73 | info: { 74 | version: '1.0.0', 75 | title: 'Swagger Petstore', 76 | description: 'A sample API', 77 | termsOfService: 'http://swagger.io/terms/', 78 | license: { 79 | name: 'Apache 2.0', 80 | url: 'https://www.apache.org/licenses/LICENSE-2.0.html', 81 | }, 82 | }, 83 | servers: [{ url: 'v1' }], 84 | }; 85 | 86 | export function generateDataForRoute( 87 | props: Partial = {}, 88 | additionalDefinitions: OpenAPIDefinitions[] = [] 89 | ): OperationObject & { 90 | documentSchemas: ComponentsObject['schemas']; 91 | documentParameters: ComponentsObject['parameters']; 92 | } { 93 | const route = createTestRoute(props); 94 | 95 | const routeDefinition = { 96 | type: 'route' as const, 97 | route, 98 | }; 99 | 100 | const { paths, components } = new OpenApiGeneratorV3([ 101 | ...additionalDefinitions, 102 | routeDefinition, 103 | ]).generateDocument(testDocConfig); 104 | 105 | const routeDoc = paths[route.path]?.[route.method] as OperationObject; 106 | 107 | return { 108 | documentSchemas: components?.schemas, 109 | documentParameters: components?.parameters, 110 | ...routeDoc, 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /spec/lodash.spec.ts: -------------------------------------------------------------------------------- 1 | import { objectEquals } from '../src/lib/lodash'; 2 | 3 | describe('Lodash', () => { 4 | describe('objectEquals', () => { 5 | it('can compare plain values', () => { 6 | expect(objectEquals(3, 4)).toEqual(false); 7 | expect(objectEquals(3, 3)).toEqual(true); 8 | 9 | expect(objectEquals('3', '4')).toEqual(false); 10 | expect(objectEquals('3', '3')).toEqual(true); 11 | }); 12 | 13 | it('can compare objects', () => { 14 | expect(objectEquals({ a: 3 }, { b: 3 })).toEqual(false); 15 | expect(objectEquals({ a: 3 }, { a: '3' })).toEqual(false); 16 | 17 | expect(objectEquals({ a: 3 }, { a: 3, b: false })).toEqual(false); 18 | 19 | expect(objectEquals({ a: 3 }, { a: 3 })).toEqual(true); 20 | }); 21 | 22 | it('can compare nested objects', () => { 23 | expect( 24 | objectEquals( 25 | { test: { a: ['asd', 3, true] } }, 26 | { test: { a: ['asd', 3, true, { b: null }] } } 27 | ) 28 | ).toEqual(false); 29 | 30 | expect( 31 | objectEquals( 32 | { test: { a: ['asd', 3, true, { b: null }] } }, 33 | { test: { a: ['asd', 3, true, { b: null }] } } 34 | ) 35 | ).toEqual(true); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /spec/metadata-overrides.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from './lib/helpers'; 3 | 4 | describe('metadata overrides', () => { 5 | it.todo( 6 | 'throws error for openapi data to be provided for unrecognized literal types' 7 | ); 8 | 9 | it.todo( 10 | 'throws error for openapi data to be provided for unrecognized enum types' 11 | ); 12 | 13 | it('does not infer the type if one is provided using .openapi', () => { 14 | const schema = z.string().openapi('StringAsNumber', { type: 'number' }); 15 | expectSchema([schema], { 16 | StringAsNumber: { type: 'number' }, 17 | }); 18 | }); 19 | 20 | it('can remove .openapi properties', () => { 21 | const schema = z 22 | .string() 23 | .openapi('Test', { description: 'test', deprecated: true }) 24 | .openapi({ description: undefined, deprecated: undefined }); 25 | 26 | expectSchema([schema], { 27 | Test: { type: 'string' }, 28 | }); 29 | }); 30 | 31 | it('generates schemas with metadata', () => { 32 | expectSchema( 33 | [z.string().openapi('SimpleString', { description: 'test' })], 34 | { SimpleString: { type: 'string', description: 'test' } } 35 | ); 36 | }); 37 | 38 | it('supports .openapi for registered schemas', () => { 39 | const StringSchema = z.string().openapi('String'); 40 | 41 | const TestSchema = z 42 | .object({ 43 | key: StringSchema.openapi({ example: 'test', deprecated: true }), 44 | }) 45 | .openapi('Test'); 46 | 47 | expectSchema([StringSchema, TestSchema], { 48 | String: { 49 | type: 'string', 50 | }, 51 | Test: { 52 | type: 'object', 53 | properties: { 54 | key: { 55 | allOf: [ 56 | { $ref: '#/components/schemas/String' }, 57 | { example: 'test', deprecated: true }, 58 | ], 59 | }, 60 | }, 61 | required: ['key'], 62 | }, 63 | }); 64 | }); 65 | 66 | it('only adds overrides for new metadata properties', () => { 67 | const StringSchema = z.string().openapi('String', { 68 | description: 'old field', 69 | title: 'same title', 70 | examples: ['same array'], 71 | discriminator: { propertyName: 'sameProperty' }, 72 | }); 73 | 74 | const TestSchema = z 75 | .object({ 76 | key: StringSchema.openapi({ 77 | title: 'same title', 78 | examples: ['same array'], 79 | example: 'new field', 80 | discriminator: { propertyName: 'sameProperty' }, 81 | }), 82 | }) 83 | .openapi('Test'); 84 | 85 | expectSchema([StringSchema, TestSchema], { 86 | String: { 87 | description: 'old field', 88 | title: 'same title', 89 | examples: ['same array'], 90 | discriminator: { propertyName: 'sameProperty' }, 91 | type: 'string', 92 | }, 93 | Test: { 94 | type: 'object', 95 | properties: { 96 | key: { 97 | allOf: [ 98 | { $ref: '#/components/schemas/String' }, 99 | { example: 'new field' }, 100 | ], 101 | }, 102 | }, 103 | required: ['key'], 104 | }, 105 | }); 106 | }); 107 | 108 | it('does not add schema calculated overrides if type is provided in .openapi', () => { 109 | const StringSchema = z.string().openapi('String', { 110 | example: 'existing field', 111 | }); 112 | 113 | const TestSchema = z 114 | .object({ 115 | key: StringSchema.nullable().openapi({ type: 'boolean' }), 116 | }) 117 | .openapi('Test'); 118 | 119 | expectSchema([StringSchema, TestSchema], { 120 | String: { 121 | example: 'existing field', 122 | type: 'string', 123 | }, 124 | Test: { 125 | type: 'object', 126 | properties: { 127 | key: { 128 | allOf: [ 129 | { $ref: '#/components/schemas/String' }, 130 | { type: 'boolean' }, 131 | ], 132 | }, 133 | }, 134 | required: ['key'], 135 | }, 136 | }); 137 | }); 138 | 139 | // This was broken with the metadata overrides code so this feels like 140 | // the best support for it 141 | it('supports referencing zod effects', () => { 142 | const EmptySchema = z 143 | .object({}) 144 | .transform(obj => obj as { [key: string]: never }) 145 | .openapi('Empty', { 146 | type: 'object', 147 | }); 148 | 149 | const TestSchema = z.object({ key: EmptySchema }).openapi('Test'); 150 | 151 | expectSchema([EmptySchema, TestSchema], { 152 | Empty: { 153 | type: 'object', 154 | }, 155 | Test: { 156 | type: 'object', 157 | required: ['key'], 158 | properties: { 159 | key: { 160 | $ref: '#/components/schemas/Empty', 161 | }, 162 | }, 163 | }, 164 | }); 165 | }); 166 | 167 | it('supports referencing zod effects in unions', () => { 168 | const EmptySchema = z 169 | .object({}) 170 | .transform(obj => obj as { [key: string]: never }) 171 | .openapi('Empty', { 172 | type: 'object', 173 | }); 174 | 175 | const UnionTestSchema = z 176 | .union([z.string(), EmptySchema]) 177 | .openapi('UnionTest', { 178 | description: 'Union with empty object', 179 | }); 180 | 181 | expectSchema([EmptySchema, UnionTestSchema], { 182 | Empty: { 183 | type: 'object', 184 | }, 185 | UnionTest: { 186 | anyOf: [{ type: 'string' }, { $ref: '#/components/schemas/Empty' }], 187 | description: 'Union with empty object', 188 | }, 189 | }); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /spec/modifiers/branded.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('branded', () => { 5 | it('generates OpenAPI schema for branded type', () => { 6 | expectSchema([z.string().brand<'color'>().openapi('SimpleStringBranded')], { 7 | SimpleStringBranded: { type: 'string' }, 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /spec/modifiers/catchall.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('catchall', () => { 5 | it('generates an additionalProperties schema for objects with catchall', () => { 6 | const schema = z.object({}).catchall(z.string()).openapi('CatchallObject'); 7 | 8 | expectSchema([schema], { 9 | CatchallObject: { 10 | type: 'object', 11 | properties: {}, 12 | additionalProperties: { 13 | type: 'string', 14 | }, 15 | }, 16 | }); 17 | }); 18 | 19 | it('generates a referenced additionalProperties schema', () => { 20 | const schema = z 21 | .object({}) 22 | .catchall(z.string().openapi('SomeString')) 23 | .openapi('CatchallObject'); 24 | 25 | expectSchema([schema], { 26 | SomeString: { 27 | type: 'string', 28 | }, 29 | CatchallObject: { 30 | type: 'object', 31 | properties: {}, 32 | additionalProperties: { 33 | $ref: '#/components/schemas/SomeString', 34 | }, 35 | }, 36 | }); 37 | }); 38 | 39 | it('can override previous catchalls', () => { 40 | const BaseSchema = z 41 | .object({ id: z.string() }) 42 | .catchall(z.string()) 43 | .openapi('Base'); 44 | const ExtendedSchema = BaseSchema.extend({ bonus: z.number() }) 45 | .catchall(z.union([z.boolean(), z.number(), z.string()])) 46 | .openapi('Extended'); 47 | 48 | expectSchema([BaseSchema, ExtendedSchema], { 49 | Base: { 50 | type: 'object', 51 | required: ['id'], 52 | properties: { 53 | id: { type: 'string' }, 54 | }, 55 | additionalProperties: { 56 | type: 'string', 57 | }, 58 | }, 59 | Extended: { 60 | allOf: [ 61 | { $ref: '#/components/schemas/Base' }, 62 | { 63 | type: 'object', 64 | required: ['bonus'], 65 | properties: { 66 | bonus: { type: 'number' }, 67 | }, 68 | additionalProperties: { 69 | anyOf: [ 70 | { type: 'boolean' }, 71 | { type: 'number' }, 72 | { type: 'string' }, 73 | ], 74 | }, 75 | }, 76 | ], 77 | }, 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /spec/modifiers/custom.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | // File as a class is not available on older node versions 5 | // so I am defining this just for testing purposes 6 | class File {} 7 | 8 | describe('custom', () => { 9 | it('generates OpenAPI schema for custom type', () => { 10 | const FileSchema = z 11 | .custom(target => target instanceof File) 12 | .openapi({ 13 | type: 'string', 14 | format: 'binary', 15 | }) 16 | .openapi('File'); 17 | 18 | expectSchema([FileSchema], { 19 | File: { 20 | type: 'string', 21 | format: 'binary', 22 | }, 23 | }); 24 | }); 25 | 26 | it('generates OpenAPI schema for custom type in object', () => { 27 | const FileUploadSchema = z 28 | .object({ 29 | file: z 30 | .custom(target => target instanceof File) 31 | .openapi({ 32 | type: 'string', 33 | format: 'binary', 34 | }), 35 | }) 36 | .openapi('FileUpload'); 37 | 38 | expectSchema([FileUploadSchema], { 39 | FileUpload: { 40 | type: 'object', 41 | properties: { 42 | file: { type: 'string', format: 'binary' }, 43 | }, 44 | required: ['file'], 45 | }, 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /spec/modifiers/deepPartial.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema, generateDataForRoute } from '../lib/helpers'; 3 | 4 | describe('describe', () => { 5 | it('generates a deepPartial object', () => { 6 | const schema = z.object({ 7 | a: z.string(), 8 | b: z.number(), 9 | }); 10 | 11 | const { requestBody } = generateDataForRoute({ 12 | request: { 13 | body: { 14 | description: 'Test description', 15 | required: true, 16 | content: { 17 | 'application/json': { 18 | schema: schema.deepPartial(), 19 | }, 20 | }, 21 | }, 22 | }, 23 | }); 24 | 25 | expect(requestBody).toEqual({ 26 | description: 'Test description', 27 | required: true, 28 | content: { 29 | 'application/json': { 30 | schema: { 31 | type: 'object', 32 | properties: { 33 | a: { type: 'string' }, 34 | b: { type: 'number' }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }); 40 | }); 41 | 42 | it('generates a deepPartial object from a registered schema', () => { 43 | const schema = z 44 | .object({ 45 | a: z.string(), 46 | b: z.number(), 47 | }) 48 | .openapi('TestSchema'); 49 | 50 | const { documentSchemas, requestBody, responses } = generateDataForRoute({ 51 | request: { 52 | body: { 53 | description: 'Test description', 54 | required: true, 55 | content: { 56 | 'application/json': { 57 | schema: schema.deepPartial(), 58 | }, 59 | }, 60 | }, 61 | }, 62 | responses: { 63 | 200: { 64 | description: 'Response description', 65 | content: { 66 | 'application/json': { schema }, 67 | }, 68 | }, 69 | }, 70 | }); 71 | 72 | // The schema is registered 73 | expect(documentSchemas).toEqual({ 74 | TestSchema: { 75 | type: 'object', 76 | properties: { 77 | a: { type: 'string' }, 78 | b: { type: 'number' }, 79 | }, 80 | required: ['a', 'b'], 81 | }, 82 | }); 83 | 84 | expect(responses[200]).toEqual({ 85 | description: 'Response description', 86 | content: { 87 | 'application/json': { 88 | schema: { $ref: '#/components/schemas/TestSchema' }, 89 | }, 90 | }, 91 | }); 92 | 93 | expect(requestBody).toEqual({ 94 | description: 'Test description', 95 | required: true, 96 | content: { 97 | 'application/json': { 98 | schema: { 99 | type: 'object', 100 | properties: { 101 | a: { type: 'string' }, 102 | b: { type: 'number' }, 103 | }, 104 | }, 105 | }, 106 | }, 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /spec/modifiers/default.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('default', () => { 5 | it('supports defaults', () => { 6 | expectSchema([z.string().default('test').openapi('StringWithDefault')], { 7 | StringWithDefault: { 8 | type: 'string', 9 | default: 'test', 10 | }, 11 | }); 12 | }); 13 | 14 | it('supports defaults override', () => { 15 | expectSchema( 16 | [ 17 | z 18 | .string() 19 | .default('test') 20 | .default('override') 21 | .openapi('StringWithDefault'), 22 | ], 23 | { 24 | StringWithDefault: { 25 | type: 'string', 26 | default: 'override', 27 | }, 28 | } 29 | ); 30 | }); 31 | 32 | it('supports falsy defaults', () => { 33 | expectSchema([z.boolean().default(false).openapi('BooleanWithDefault')], { 34 | BooleanWithDefault: { 35 | type: 'boolean', 36 | default: false, 37 | }, 38 | }); 39 | }); 40 | 41 | it('supports optional defaults', () => { 42 | const schema = z 43 | .object({ test: z.ostring().default('test') }) 44 | .openapi('ObjectWithDefault'); 45 | 46 | expectSchema([schema], { 47 | ObjectWithDefault: { 48 | type: 'object', 49 | properties: { 50 | test: { 51 | type: 'string', 52 | default: 'test', 53 | }, 54 | }, 55 | }, 56 | }); 57 | }); 58 | 59 | it('supports required types with defaults', () => { 60 | const schema = z 61 | .object({ 62 | test: z.string().default('test'), 63 | }) 64 | .openapi('ObjectWithDefault'); 65 | 66 | expectSchema([schema], { 67 | ObjectWithDefault: { 68 | type: 'object', 69 | properties: { 70 | test: { 71 | type: 'string', 72 | default: 'test', 73 | }, 74 | }, 75 | }, 76 | }); 77 | }); 78 | 79 | it('supports optional default schemas with refine', () => { 80 | expectSchema( 81 | [ 82 | z 83 | .object({ 84 | test: z 85 | .onumber() 86 | .default(42) 87 | .refine(num => num && num % 2 === 0), 88 | }) 89 | .openapi('Object'), 90 | ], 91 | { 92 | Object: { 93 | type: 'object', 94 | properties: { 95 | test: { 96 | type: 'number', 97 | default: 42, 98 | }, 99 | }, 100 | }, 101 | } 102 | ); 103 | }); 104 | 105 | it('supports required types with default and refine', () => { 106 | expectSchema( 107 | [ 108 | z 109 | .object({ 110 | test: z 111 | .number() 112 | .default(42) 113 | .refine(num => num && num % 2 === 0), 114 | }) 115 | .openapi('Object'), 116 | ], 117 | { 118 | Object: { 119 | type: 'object', 120 | properties: { 121 | test: { 122 | type: 'number', 123 | default: 42, 124 | }, 125 | }, 126 | }, 127 | } 128 | ); 129 | }); 130 | 131 | it('supports overriding default with .openapi', () => { 132 | expectSchema( 133 | [ 134 | z 135 | .enum(['a', 'b']) 136 | .default('a') 137 | .openapi('EnumWithDefault', { default: 'b', examples: ['b'] }), 138 | ], 139 | { 140 | EnumWithDefault: { 141 | default: 'b', 142 | enum: ['a', 'b'], 143 | type: 'string', 144 | examples: ['b'], 145 | }, 146 | } 147 | ); 148 | }); 149 | 150 | it('supports input type examples with default', () => { 151 | const example = { 152 | requiredField: 'required', 153 | }; 154 | 155 | const schema = z 156 | .object({ 157 | optionalField: z.string().default('defaultValue'), 158 | requiredField: z.string(), 159 | }) 160 | // This throws an error if z.infer was used (as before) 161 | .openapi('TestTypescriptExample', { example }); 162 | 163 | expectSchema([schema], { 164 | TestTypescriptExample: { 165 | type: 'object', 166 | properties: { 167 | optionalField: { 168 | type: 'string', 169 | default: 'defaultValue', 170 | }, 171 | requiredField: { 172 | type: 'string', 173 | }, 174 | }, 175 | example: { 176 | requiredField: 'required', 177 | }, 178 | required: ['requiredField'], 179 | }, 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /spec/modifiers/describe.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema, generateDataForRoute } from '../lib/helpers'; 3 | 4 | describe('describe', () => { 5 | it('generates OpenAPI schema with description when the .describe method is used', () => { 6 | const schema = z 7 | .string() 8 | .describe('This is a test string') 9 | .openapi('SimpleString'); 10 | 11 | expectSchema([schema], { 12 | SimpleString: { type: 'string', description: 'This is a test string' }, 13 | }); 14 | }); 15 | 16 | it('can get description from a schema made optional', () => { 17 | const schema = z 18 | .string() 19 | .describe('This is a test string') 20 | .optional() 21 | .openapi('SimpleString'); 22 | 23 | expectSchema([schema], { 24 | SimpleString: { type: 'string', description: 'This is a test string' }, 25 | }); 26 | }); 27 | 28 | it('can get description from an optional schema', () => { 29 | const schema = z 30 | .string() 31 | .optional() 32 | .describe('This is a test string') 33 | .openapi('SimpleString'); 34 | 35 | expectSchema([schema], { 36 | SimpleString: { type: 'string', description: 'This is a test string' }, 37 | }); 38 | }); 39 | 40 | it('can overload descriptions from .describe with .openapi', () => { 41 | const schema = z 42 | .string() 43 | .describe('This is a test string') 44 | .openapi('SimpleString', { description: 'Alternative description' }); 45 | 46 | expectSchema([schema], { 47 | SimpleString: { 48 | type: 'string', 49 | description: 'Alternative description', 50 | }, 51 | }); 52 | }); 53 | 54 | it('can use nested descriptions from .describe with .openapi', () => { 55 | const schema = z 56 | .object({ 57 | type: z.string().describe('Just a type'), 58 | title: z.string().describe('Just a title').optional(), 59 | }) 60 | .describe('Whole object') 61 | .openapi('Test'); 62 | 63 | expectSchema([schema], { 64 | Test: { 65 | type: 'object', 66 | properties: { 67 | type: { 68 | type: 'string', 69 | description: 'Just a type', 70 | }, 71 | title: { 72 | type: 'string', 73 | description: 'Just a title', 74 | }, 75 | }, 76 | required: ['type'], 77 | description: 'Whole object', 78 | }, 79 | }); 80 | }); 81 | 82 | it('generates an optional query parameter with a provided description', () => { 83 | const { parameters } = generateDataForRoute({ 84 | request: { 85 | query: z.object({ 86 | test: z.string().optional().describe('Some parameter'), 87 | }), 88 | }, 89 | }); 90 | 91 | expect(parameters).toEqual([ 92 | { 93 | in: 'query', 94 | name: 'test', 95 | description: 'Some parameter', 96 | required: false, 97 | schema: { 98 | description: 'Some parameter', 99 | type: 'string', 100 | }, 101 | }, 102 | ]); 103 | }); 104 | 105 | it('generates a query parameter with a description made optional', () => { 106 | const { parameters } = generateDataForRoute({ 107 | request: { 108 | query: z.object({ 109 | test: z.string().describe('Some parameter').optional(), 110 | }), 111 | }, 112 | }); 113 | 114 | expect(parameters).toEqual([ 115 | { 116 | in: 'query', 117 | name: 'test', 118 | description: 'Some parameter', 119 | required: false, 120 | schema: { 121 | description: 'Some parameter', 122 | type: 'string', 123 | }, 124 | }, 125 | ]); 126 | }); 127 | 128 | it('generates a query parameter with description from a registered schema', () => { 129 | const schema = z.string().describe('Some parameter').openapi('SomeString'); 130 | const { parameters, documentSchemas } = generateDataForRoute({ 131 | request: { 132 | query: z.object({ test: schema }), 133 | }, 134 | }); 135 | 136 | expect(documentSchemas).toEqual({ 137 | SomeString: { 138 | type: 'string', 139 | description: 'Some parameter', 140 | }, 141 | }); 142 | 143 | expect(parameters).toEqual([ 144 | { 145 | in: 'query', 146 | name: 'test', 147 | description: 'Some parameter', 148 | required: true, 149 | schema: { 150 | $ref: '#/components/schemas/SomeString', 151 | }, 152 | }, 153 | ]); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /spec/modifiers/instanceof.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | // File as a class is not available on older node versions 5 | // so I am defining this just for testing purposes 6 | class File {} 7 | 8 | describe('instanceof', () => { 9 | it('generates OpenAPI schema for instanceof type', () => { 10 | const FileSchema = z 11 | .instanceof(File) 12 | .openapi({ 13 | type: 'string', 14 | format: 'binary', 15 | }) 16 | .openapi('File'); 17 | 18 | expectSchema([FileSchema], { 19 | File: { 20 | type: 'string', 21 | format: 'binary', 22 | }, 23 | }); 24 | }); 25 | 26 | it('generates OpenAPI schema for instanceof type in object', () => { 27 | const FileUploadSchema = z 28 | .object({ 29 | file: z.instanceof(File).openapi({ 30 | type: 'string', 31 | format: 'binary', 32 | }), 33 | }) 34 | .openapi('FileUpload'); 35 | 36 | expectSchema([FileUploadSchema], { 37 | FileUpload: { 38 | type: 'object', 39 | properties: { 40 | file: { type: 'string', format: 'binary' }, 41 | }, 42 | required: ['file'], 43 | }, 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /spec/modifiers/nullable.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('nullable', () => { 5 | it('supports nullable', () => { 6 | expectSchema([z.string().openapi('NullableString').nullable()], { 7 | NullableString: { type: 'string', nullable: true }, 8 | }); 9 | }); 10 | 11 | it('supports nullable for registered schemas', () => { 12 | const StringSchema = z.string().openapi('String'); 13 | 14 | const TestSchema = z 15 | .object({ key: StringSchema.nullable() }) 16 | .openapi('Test'); 17 | 18 | expectSchema([StringSchema, TestSchema], { 19 | String: { 20 | type: 'string', 21 | }, 22 | Test: { 23 | type: 'object', 24 | properties: { 25 | key: { 26 | allOf: [ 27 | { $ref: '#/components/schemas/String' }, 28 | { nullable: true }, 29 | ], 30 | }, 31 | }, 32 | required: ['key'], 33 | }, 34 | }); 35 | }); 36 | 37 | it('should not apply nullable if the schema is already nullable', () => { 38 | const StringSchema = z.string().openapi('String').nullable(); 39 | 40 | const TestSchema = z 41 | .object({ key: StringSchema.nullable() }) 42 | .openapi('Test'); 43 | 44 | expectSchema([StringSchema, TestSchema], { 45 | String: { 46 | type: 'string', 47 | nullable: true, 48 | }, 49 | Test: { 50 | type: 'object', 51 | properties: { 52 | key: { $ref: '#/components/schemas/String' }, 53 | }, 54 | required: ['key'], 55 | }, 56 | }); 57 | }); 58 | 59 | it('supports nullable in open api 3.1.0', () => { 60 | expectSchema( 61 | [z.string().nullable().openapi('NullableString')], 62 | { 63 | NullableString: { type: ['string', 'null'] }, 64 | }, 65 | '3.1.0' 66 | ); 67 | }); 68 | 69 | it('supports nullable for registered schemas in open api 3.1.0', () => { 70 | const StringSchema = z.string().openapi('String'); 71 | 72 | const TestSchema = z 73 | .object({ key: StringSchema.nullable() }) 74 | .openapi('Test'); 75 | 76 | expectSchema( 77 | [StringSchema, TestSchema], 78 | { 79 | String: { 80 | type: 'string', 81 | }, 82 | Test: { 83 | type: 'object', 84 | properties: { 85 | key: { 86 | allOf: [ 87 | { $ref: '#/components/schemas/String' }, 88 | { type: ['string', 'null'] }, 89 | ], 90 | }, 91 | }, 92 | required: ['key'], 93 | }, 94 | }, 95 | '3.1.0' 96 | ); 97 | }); 98 | 99 | it('should not apply nullable if the schema is already nullable in open api 3.1.0', () => { 100 | const StringSchema = z.string().nullable().openapi('String'); 101 | 102 | const TestSchema = z 103 | .object({ key: StringSchema.nullable() }) 104 | .openapi('Test'); 105 | 106 | expectSchema( 107 | [StringSchema, TestSchema], 108 | { 109 | String: { 110 | type: ['string', 'null'], 111 | }, 112 | Test: { 113 | type: 'object', 114 | properties: { 115 | key: { $ref: '#/components/schemas/String' }, 116 | }, 117 | required: ['key'], 118 | }, 119 | }, 120 | '3.1.0' 121 | ); 122 | }); 123 | 124 | it('supports referencing nullable zod effects', () => { 125 | const EmptySchema = z 126 | .object({}) 127 | .transform(obj => obj as { [key: string]: never }) 128 | .openapi('Empty', { 129 | type: 'object', 130 | }); 131 | 132 | const TestSchema = z 133 | .object({ key: EmptySchema.nullable().openapi({ deprecated: true }) }) 134 | .openapi('Test'); 135 | 136 | expectSchema([EmptySchema, TestSchema], { 137 | Empty: { 138 | type: 'object', 139 | }, 140 | Test: { 141 | type: 'object', 142 | required: ['key'], 143 | properties: { 144 | key: { 145 | allOf: [ 146 | { 147 | $ref: '#/components/schemas/Empty', 148 | }, 149 | { 150 | nullable: true, 151 | deprecated: true, 152 | }, 153 | ], 154 | }, 155 | }, 156 | }, 157 | }); 158 | }); 159 | 160 | it('supports referencing nullable zod effects with Openapi v3.1.0', () => { 161 | const EmptySchema = z 162 | .object({}) 163 | .transform(obj => obj as { [key: string]: never }) 164 | .openapi('Empty', { 165 | type: 'object', 166 | }); 167 | 168 | const TestSchema = z 169 | .object({ 170 | key: EmptySchema.nullable().openapi({ 171 | deprecated: true, 172 | }), 173 | }) 174 | .openapi('Test'); 175 | 176 | expectSchema( 177 | [EmptySchema, TestSchema], 178 | { 179 | Empty: { 180 | type: 'object', 181 | }, 182 | Test: { 183 | type: 'object', 184 | required: ['key'], 185 | properties: { 186 | key: { 187 | allOf: [ 188 | { 189 | $ref: '#/components/schemas/Empty', 190 | }, 191 | { 192 | type: ['object', 'null'], 193 | deprecated: true, 194 | }, 195 | ], 196 | }, 197 | }, 198 | }, 199 | }, 200 | '3.1.0' 201 | ); 202 | }); 203 | 204 | it('overrides zod nullable when there is specified type in openapi', () => { 205 | const EmptySchema = z 206 | .object({}) 207 | .nullable() 208 | .transform(obj => obj as { [key: string]: never }) 209 | .openapi('Empty', { 210 | type: 'object', 211 | }); 212 | 213 | expectSchema([EmptySchema], { 214 | Empty: { 215 | type: 'object', 216 | }, 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /spec/modifiers/optional.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('optional', () => { 5 | it('generates OpenAPI schema for optional after the metadata', () => { 6 | expectSchema([z.string().optional().openapi('SimpleString')], { 7 | SimpleString: { type: 'string' }, 8 | }); 9 | }); 10 | 11 | it('generates OpenAPI schema for optional before the metadata', () => { 12 | expectSchema([z.string().optional().openapi('SimpleString')], { 13 | SimpleString: { type: 'string' }, 14 | }); 15 | }); 16 | 17 | it('supports optional nullable', () => { 18 | expectSchema( 19 | [ 20 | z 21 | .object({ 22 | test: z.string().nullable().optional(), 23 | }) 24 | .openapi('SimpleObject'), 25 | ], 26 | { 27 | SimpleObject: { 28 | type: 'object', 29 | properties: { 30 | test: { 31 | nullable: true, 32 | type: 'string', 33 | }, 34 | }, 35 | }, 36 | } 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /spec/modifiers/pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('pipe', () => { 5 | it('can generate schema for pipes', () => { 6 | expectSchema( 7 | [ 8 | z 9 | .date() 10 | .or(z.string().min(1).pipe(z.coerce.date())) 11 | .openapi('PipedDate'), 12 | ], 13 | { 14 | PipedDate: { 15 | anyOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], 16 | }, 17 | }, 18 | '3.1.0' 19 | ); 20 | }); 21 | 22 | it('can generate schema for pipes with internal type transformation', () => { 23 | expectSchema( 24 | [ 25 | z 26 | .number() 27 | .or(z.string()) 28 | .pipe(z.coerce.number()) 29 | .openapi('PipedNumber'), 30 | ], 31 | { 32 | PipedNumber: { anyOf: [{ type: 'number' }, { type: 'string' }] }, 33 | } 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /spec/modifiers/preprocess.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('preprocess', () => { 5 | it('supports preprocessed string -> boolean schema', () => { 6 | expectSchema( 7 | [ 8 | z 9 | .preprocess(arg => { 10 | if (typeof arg === 'boolean') { 11 | return arg; 12 | } 13 | 14 | if (typeof arg === 'string') { 15 | if (arg === 'true') return true; 16 | if (arg === 'false') return false; 17 | } 18 | 19 | return undefined; 20 | }, z.boolean()) 21 | .openapi('PreprocessedBoolean'), 22 | ], 23 | { 24 | PreprocessedBoolean: { 25 | type: 'boolean', 26 | }, 27 | } 28 | ); 29 | }); 30 | 31 | it('supports preprocessed string -> number schema', () => { 32 | expectSchema( 33 | [ 34 | z 35 | .preprocess(arg => { 36 | if (typeof arg === 'number') { 37 | return arg; 38 | } 39 | 40 | if (typeof arg === 'string') { 41 | return parseInt(arg, 10); 42 | } 43 | 44 | return undefined; 45 | }, z.number()) 46 | .openapi('PreprocessedNumber'), 47 | ], 48 | { 49 | PreprocessedNumber: { 50 | type: 'number', 51 | }, 52 | } 53 | ); 54 | }); 55 | 56 | // TODO: This test should probably be made to work. 57 | it.skip('can automatically register schemas in preprocess', () => { 58 | const schema = z 59 | .preprocess(arg => { 60 | if (typeof arg === 'boolean') { 61 | return arg; 62 | } 63 | 64 | if (typeof arg === 'string') { 65 | if (arg === 'true') return true; 66 | if (arg === 'false') return false; 67 | } 68 | 69 | return undefined; 70 | }, z.boolean().openapi('PlainBoolean')) 71 | .openapi('PreprocessedBoolean'); 72 | 73 | expectSchema([schema], { 74 | PlainBoolean: { 75 | type: 'boolean', 76 | }, 77 | PreprocessedBoolean: { 78 | type: 'boolean', 79 | }, 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /spec/modifiers/readonly.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('readonly', () => { 5 | it('supports readonly', () => { 6 | expectSchema([z.string().readonly().openapi('ReadonlyString')], { 7 | ReadonlyString: { 8 | type: 'string', 9 | }, 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /spec/modifiers/refine.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('refine', () => { 5 | it('supports refined schemas', () => { 6 | expectSchema( 7 | [ 8 | z 9 | .number() 10 | .refine(num => num % 2 === 0) 11 | .openapi('RefinedString'), 12 | ], 13 | { 14 | RefinedString: { 15 | type: 'number', 16 | }, 17 | } 18 | ); 19 | }); 20 | 21 | it('does not lose metadata from refine', () => { 22 | expectSchema( 23 | [ 24 | z 25 | .number() 26 | .openapi({ example: 42 }) 27 | .refine(num => num % 2 === 0) 28 | .openapi('RefinedString'), 29 | ], 30 | { 31 | RefinedString: { 32 | type: 'number', 33 | example: 42, 34 | }, 35 | } 36 | ); 37 | }); 38 | 39 | it('supports required refined schemas', () => { 40 | expectSchema( 41 | [ 42 | z 43 | .object({ 44 | test: z.number().refine(num => num && num % 2 === 0), 45 | }) 46 | .openapi('ObjectWithRefinedString'), 47 | ], 48 | { 49 | ObjectWithRefinedString: { 50 | type: 'object', 51 | properties: { 52 | test: { 53 | type: 'number', 54 | }, 55 | }, 56 | required: ['test'], 57 | }, 58 | } 59 | ); 60 | }); 61 | 62 | it('supports optional refined schemas', () => { 63 | expectSchema( 64 | [ 65 | z 66 | .object({ 67 | test: z.onumber().refine(num => !num || num % 2 === 0), 68 | }) 69 | .openapi('ObjectWithRefinedString'), 70 | ], 71 | { 72 | ObjectWithRefinedString: { 73 | type: 'object', 74 | properties: { 75 | test: { 76 | type: 'number', 77 | }, 78 | }, 79 | }, 80 | } 81 | ); 82 | }); 83 | 84 | it('supports optional refined schemas with default', () => { 85 | expectSchema( 86 | [ 87 | z 88 | .object({ 89 | test: z 90 | .onumber() 91 | .refine(num => num && num % 2 === 0) 92 | .default(42), 93 | }) 94 | .openapi('Object'), 95 | ], 96 | { 97 | Object: { 98 | type: 'object', 99 | properties: { 100 | test: { 101 | type: 'number', 102 | default: 42, 103 | }, 104 | }, 105 | }, 106 | } 107 | ); 108 | }); 109 | 110 | it('supports required type schemas with refine and default', () => { 111 | expectSchema( 112 | [ 113 | z 114 | .object({ 115 | test: z 116 | .number() 117 | .refine(num => num && num % 2 === 0) 118 | .default(42), 119 | }) 120 | .openapi('Object'), 121 | ], 122 | { 123 | Object: { 124 | type: 'object', 125 | properties: { 126 | test: { 127 | type: 'number', 128 | default: 42, 129 | }, 130 | }, 131 | }, 132 | } 133 | ); 134 | }); 135 | 136 | it('supports refined transforms when type is provided', () => { 137 | expectSchema( 138 | [ 139 | z 140 | .object({ 141 | test: z 142 | .string() 143 | .transform(value => value.trim()) 144 | .refine(val => val.length >= 1, 'Value not set.') 145 | .openapi({ 146 | type: 'string', 147 | }), 148 | }) 149 | .openapi('Object'), 150 | ], 151 | { 152 | Object: { 153 | type: 'object', 154 | properties: { 155 | test: { 156 | type: 'string', 157 | }, 158 | }, 159 | required: ['test'], 160 | }, 161 | } 162 | ); 163 | }); 164 | 165 | // TODO: This test should probably be made to work. 166 | it.skip('can automatically register schemas in refine', () => { 167 | const schema = z 168 | .string() 169 | .openapi('PlainString') 170 | .refine(data => data.length > 3) 171 | .openapi('RefinedString'); 172 | 173 | expectSchema([schema], { 174 | PlainString: { 175 | type: 'string', 176 | }, 177 | RefinedString: { 178 | type: 'string', 179 | }, 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /spec/modifiers/transform.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('transform', () => { 5 | it('does support transformed schemas', () => { 6 | expectSchema( 7 | [ 8 | z 9 | .number() 10 | .transform(num => num.toString()) 11 | .openapi('Transformed'), 12 | ], 13 | { 14 | Transformed: { 15 | type: 'number', 16 | }, 17 | } 18 | ); 19 | }); 20 | 21 | it('does not lose metadata from transform', () => { 22 | expectSchema( 23 | [ 24 | z 25 | .number() 26 | .openapi({ example: 42 }) 27 | .transform(num => num.toString()) 28 | .openapi('Transformed'), 29 | ], 30 | { 31 | Transformed: { 32 | type: 'number', 33 | example: 42, 34 | }, 35 | } 36 | ); 37 | }); 38 | 39 | it('supports input type examples with transform', () => { 40 | const schema = z 41 | .string() 42 | .transform(val => val.length) 43 | .openapi('TestTypescriptExample', { example: '123' }); 44 | 45 | expectSchema([schema], { 46 | TestTypescriptExample: { 47 | type: 'string', 48 | example: '123', 49 | }, 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /spec/openapi-metadata.spec.ts: -------------------------------------------------------------------------------- 1 | import { getOpenApiMetadata } from '../src/openapi-metadata'; 2 | import { z } from 'zod'; 3 | 4 | describe('OpenAPI metadata', () => { 5 | it('can obtain nested metadata', () => { 6 | const schema = z 7 | .string() 8 | .openapi({ description: 'Test', deprecated: true }) 9 | .optional() 10 | .nullable() 11 | .default('test'); 12 | 13 | expect(getOpenApiMetadata(schema)).toEqual({ 14 | description: 'Test', 15 | deprecated: true, 16 | }); 17 | }); 18 | 19 | it('can obtain overridden metadata', () => { 20 | const schema = z 21 | .string() 22 | .openapi({ description: 'Test' }) 23 | .optional() 24 | .openapi({ deprecated: true }) 25 | .nullable() 26 | .openapi({ example: 'test-example' }) 27 | .default('test') 28 | .openapi({ maxLength: 40 }); 29 | 30 | expect(getOpenApiMetadata(schema)).toEqual({ 31 | description: 'Test', 32 | deprecated: true, 33 | example: 'test-example', 34 | maxLength: 40, 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /spec/registry.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { OpenAPIRegistry } from '../src'; 3 | import { ZodOpenApiFullMetadata } from '../src/zod-extensions'; 4 | 5 | function expectToHaveMetadata(expectedMetadata: ZodOpenApiFullMetadata) { 6 | return expect.objectContaining({ 7 | _def: expect.objectContaining({ 8 | openapi: expectedMetadata, 9 | }), 10 | }); 11 | } 12 | 13 | describe('OpenAPIRegistry', () => { 14 | it('can create schema definition', () => { 15 | const registry = new OpenAPIRegistry(); 16 | 17 | registry.register('Test', z.string()); 18 | 19 | expect(registry.definitions).toEqual([ 20 | { 21 | type: 'schema', 22 | schema: expectToHaveMetadata({ _internal: { refId: 'Test' } }), 23 | }, 24 | ]); 25 | }); 26 | 27 | it('can create schema definition with additional metadata', () => { 28 | const registry = new OpenAPIRegistry(); 29 | 30 | registry.register( 31 | 'Test', 32 | z.string().openapi({ description: 'Some string', deprecated: true }) 33 | ); 34 | 35 | expect(registry.definitions).toEqual([ 36 | { 37 | type: 'schema', 38 | schema: expectToHaveMetadata({ 39 | _internal: { refId: 'Test' }, 40 | metadata: { description: 'Some string', deprecated: true }, 41 | }), 42 | }, 43 | ]); 44 | }); 45 | 46 | it('can create parameter definition', () => { 47 | const registry = new OpenAPIRegistry(); 48 | 49 | registry.registerParameter('Test', z.string()); 50 | 51 | expect(registry.definitions).toEqual([ 52 | { 53 | type: 'parameter', 54 | schema: expectToHaveMetadata({ 55 | _internal: { refId: 'Test' }, 56 | metadata: { param: { name: 'Test' } }, 57 | }), 58 | }, 59 | ]); 60 | }); 61 | 62 | it('can create parameter definition with additional metadata', () => { 63 | const registry = new OpenAPIRegistry(); 64 | 65 | registry.registerParameter( 66 | 'Test', 67 | z.string().openapi({ description: 'Some string', param: { in: 'query' } }) 68 | ); 69 | 70 | expect(registry.definitions).toEqual([ 71 | { 72 | type: 'parameter', 73 | schema: expectToHaveMetadata({ 74 | _internal: { refId: 'Test' }, 75 | metadata: { 76 | description: 'Some string', 77 | param: { name: 'Test', in: 'query' }, 78 | }, 79 | }), 80 | }, 81 | ]); 82 | }); 83 | 84 | it('preserves name given with .openapi over the reference name', () => { 85 | const registry = new OpenAPIRegistry(); 86 | 87 | registry.registerParameter( 88 | 'referenceName', 89 | z.string().openapi({ param: { name: 'actualName' } }) 90 | ); 91 | 92 | expect(registry.definitions).toEqual([ 93 | { 94 | type: 'parameter', 95 | schema: expectToHaveMetadata({ 96 | _internal: { refId: 'referenceName' }, 97 | metadata: { 98 | param: { name: 'actualName' }, 99 | }, 100 | }), 101 | }, 102 | ]); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /spec/routes/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { ZodSchema, z } from 'zod'; 2 | import { 3 | OpenAPIDefinitions, 4 | OpenAPIRegistry, 5 | } from '../../src/openapi-registry'; 6 | import { 7 | createTestRoute, 8 | generateDataForRoute, 9 | testDocConfig, 10 | } from '../lib/helpers'; 11 | import { OpenApiGeneratorV31 } from '../../src/v3.1/openapi-generator'; 12 | 13 | // We need OpenAPIObject31 because of the webhooks property. 14 | // All tests can probably be refactored to use generateDataForRoute instead 15 | function generateDocumentWithPossibleWebhooks( 16 | definitions: (OpenAPIDefinitions | ZodSchema)[] 17 | ) { 18 | return new OpenApiGeneratorV31(definitions).generateDocument({ 19 | ...testDocConfig, 20 | openapi: '3.1.0', 21 | }); 22 | } 23 | 24 | const routeTests = ({ 25 | registerFunction, 26 | rootDocPath, 27 | }: { 28 | registerFunction: 'registerPath' | 'registerWebhook'; 29 | rootDocPath: 'paths' | 'webhooks'; 30 | }) => { 31 | describe('response definitions', () => { 32 | it('can set description', () => { 33 | const registry = new OpenAPIRegistry(); 34 | 35 | registry[registerFunction]({ 36 | method: 'get', 37 | path: '/', 38 | responses: { 39 | 200: { 40 | description: 'Simple response', 41 | content: { 42 | 'application/json': { 43 | schema: z.string(), 44 | }, 45 | }, 46 | }, 47 | 48 | 404: { 49 | description: 'Missing object', 50 | content: { 51 | 'application/json': { 52 | schema: z.string(), 53 | }, 54 | }, 55 | }, 56 | }, 57 | }); 58 | 59 | const document = generateDocumentWithPossibleWebhooks( 60 | registry.definitions 61 | ); 62 | const responses = document[rootDocPath]?.['/']?.get?.responses; 63 | 64 | expect(responses?.['200'].description).toEqual('Simple response'); 65 | expect(responses?.['404'].description).toEqual('Missing object'); 66 | }); 67 | 68 | it('can specify response with plain OpenApi format', () => { 69 | const registry = new OpenAPIRegistry(); 70 | 71 | registry[registerFunction]({ 72 | method: 'get', 73 | path: '/', 74 | responses: { 75 | 200: { 76 | description: 'Simple response', 77 | content: { 78 | 'application/json': { 79 | schema: { 80 | type: 'string', 81 | example: 'test', 82 | }, 83 | }, 84 | }, 85 | }, 86 | 87 | 404: { 88 | description: 'Missing object', 89 | content: { 90 | 'application/json': { 91 | schema: { 92 | $ref: '#/components/schemas/SomeRef', 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | } as const); 99 | 100 | const document = generateDocumentWithPossibleWebhooks( 101 | registry.definitions 102 | ); 103 | const responses = document[rootDocPath]?.['/']?.get?.responses; 104 | 105 | expect(responses?.['200'].content['application/json'].schema).toEqual({ 106 | type: 'string', 107 | example: 'test', 108 | }); 109 | expect(responses?.['404'].content['application/json'].schema).toEqual({ 110 | $ref: '#/components/schemas/SomeRef', 111 | }); 112 | }); 113 | 114 | it('can set multiple response formats', () => { 115 | const registry = new OpenAPIRegistry(); 116 | 117 | const UserSchema = registry.register( 118 | 'User', 119 | z.object({ name: z.string() }) 120 | ); 121 | 122 | registry[registerFunction]({ 123 | method: 'get', 124 | path: '/', 125 | responses: { 126 | 200: { 127 | description: 'Simple response', 128 | content: { 129 | 'application/json': { 130 | schema: UserSchema, 131 | }, 132 | 'application/xml': { 133 | schema: UserSchema, 134 | }, 135 | }, 136 | }, 137 | }, 138 | }); 139 | 140 | const document = generateDocumentWithPossibleWebhooks( 141 | registry.definitions 142 | ); 143 | const responses = document[rootDocPath]?.['/']?.get?.responses; 144 | 145 | expect(responses?.['200'].description).toEqual('Simple response'); 146 | expect(responses?.['200'].content['application/json'].schema).toEqual({ 147 | $ref: '#/components/schemas/User', 148 | }); 149 | expect(responses?.['200'].content['application/xml'].schema).toEqual({ 150 | $ref: '#/components/schemas/User', 151 | }); 152 | }); 153 | 154 | it('can generate responses without content', () => { 155 | const registry = new OpenAPIRegistry(); 156 | 157 | registry[registerFunction]({ 158 | method: 'get', 159 | path: '/', 160 | responses: { 161 | 204: { 162 | description: 'Success', 163 | }, 164 | }, 165 | }); 166 | 167 | const document = generateDocumentWithPossibleWebhooks( 168 | registry.definitions 169 | ); 170 | const responses = document[rootDocPath]?.['/']?.get?.responses; 171 | 172 | expect(responses?.['204']).toEqual({ description: 'Success' }); 173 | }); 174 | 175 | it('can generate response headers', () => { 176 | const registry = new OpenAPIRegistry(); 177 | 178 | registry[registerFunction]({ 179 | method: 'get', 180 | path: '/', 181 | responses: { 182 | 204: { 183 | description: 'Success', 184 | headers: z.object({ 185 | 'Set-Cookie': z.string().openapi({ 186 | example: 'token=test', 187 | description: 'Some string value', 188 | param: { 189 | description: 'JWT session cookie', 190 | }, 191 | }), 192 | }), 193 | }, 194 | }, 195 | }); 196 | 197 | const document = generateDocumentWithPossibleWebhooks( 198 | registry.definitions 199 | ); 200 | const responses = document[rootDocPath]?.['/']?.get?.responses; 201 | 202 | expect(responses?.['204']).toEqual({ 203 | description: 'Success', 204 | headers: { 205 | 'Set-Cookie': { 206 | schema: { 207 | type: 'string', 208 | example: 'token=test', 209 | description: 'Some string value', 210 | }, 211 | description: 'JWT session cookie', 212 | required: true, 213 | }, 214 | }, 215 | }); 216 | }); 217 | 218 | it('can automatically register response body data', () => { 219 | const Person = z 220 | .object({ 221 | name: z.string(), 222 | }) 223 | .openapi('Person'); 224 | 225 | const { documentSchemas, responses } = generateDataForRoute({ 226 | responses: { 227 | '200': { 228 | description: 'Test response', 229 | content: { 230 | 'application/json': { 231 | schema: Person, 232 | }, 233 | }, 234 | }, 235 | }, 236 | }); 237 | 238 | expect(documentSchemas).toEqual({ 239 | Person: { 240 | type: 'object', 241 | properties: { 242 | name: { type: 'string' }, 243 | }, 244 | required: ['name'], 245 | }, 246 | }); 247 | 248 | const response = responses['200'].content['application/json']; 249 | 250 | expect(response).toEqual({ 251 | schema: { 252 | $ref: '#/components/schemas/Person', 253 | }, 254 | }); 255 | }); 256 | }); 257 | 258 | it('can generate paths with multiple examples', () => { 259 | const registry = new OpenAPIRegistry(); 260 | 261 | registry[registerFunction]({ 262 | method: 'get', 263 | path: '/', 264 | responses: { 265 | 400: { 266 | description: 'Failure', 267 | content: { 268 | 'application/json': { 269 | schema: z.string(), 270 | examples: { 271 | example0: { 272 | value: 'Oh no', 273 | }, 274 | example1: { 275 | value: 'Totally gone wrong', 276 | }, 277 | }, 278 | }, 279 | }, 280 | }, 281 | }, 282 | }); 283 | 284 | const document = generateDocumentWithPossibleWebhooks(registry.definitions); 285 | const responses = document[rootDocPath]?.['/']?.get?.responses; 286 | 287 | expect(responses?.['400']).toEqual( 288 | expect.objectContaining({ description: 'Failure' }) 289 | ); 290 | 291 | const examples = responses?.['400']?.content['application/json']?.examples; 292 | 293 | expect(examples).toEqual({ 294 | example0: { 295 | value: 'Oh no', 296 | }, 297 | example1: { 298 | value: 'Totally gone wrong', 299 | }, 300 | }); 301 | }); 302 | 303 | describe('request body', () => { 304 | it('can specify request body metadata - description/required', () => { 305 | const registry = new OpenAPIRegistry(); 306 | 307 | const route = createTestRoute({ 308 | request: { 309 | body: { 310 | description: 'Test description', 311 | required: true, 312 | content: { 313 | 'application/json': { 314 | schema: z.string(), 315 | }, 316 | }, 317 | }, 318 | }, 319 | }); 320 | 321 | registry[registerFunction](route); 322 | 323 | const document = generateDocumentWithPossibleWebhooks( 324 | registry.definitions 325 | ); 326 | 327 | const requestBody = document[rootDocPath]?.['/']?.get?.requestBody; 328 | 329 | expect(requestBody).toEqual({ 330 | description: 'Test description', 331 | required: true, 332 | content: { 'application/json': { schema: { type: 'string' } } }, 333 | }); 334 | }); 335 | 336 | it('can specify request body using plain OpenApi format', () => { 337 | const registry = new OpenAPIRegistry(); 338 | 339 | const route = createTestRoute({ 340 | request: { 341 | body: { 342 | content: { 343 | 'application/json': { 344 | schema: { 345 | type: 'string', 346 | enum: ['test'], 347 | }, 348 | }, 349 | 'application/xml': { 350 | schema: { $ref: '#/components/schemas/SomeRef' }, 351 | }, 352 | }, 353 | }, 354 | }, 355 | }); 356 | 357 | registry[registerFunction](route); 358 | 359 | const document = generateDocumentWithPossibleWebhooks( 360 | registry.definitions 361 | ); 362 | 363 | const requestBody = ( 364 | document[rootDocPath]?.['/']?.get?.requestBody as any 365 | ).content; 366 | 367 | expect(requestBody['application/json']).toEqual({ 368 | schema: { type: 'string', enum: ['test'] }, 369 | }); 370 | 371 | expect(requestBody['application/xml']).toEqual({ 372 | schema: { $ref: '#/components/schemas/SomeRef' }, 373 | }); 374 | }); 375 | 376 | it('can have multiple media format bodies', () => { 377 | const registry = new OpenAPIRegistry(); 378 | 379 | const UserSchema = registry.register( 380 | 'User', 381 | z.object({ name: z.string() }) 382 | ); 383 | 384 | const route = createTestRoute({ 385 | request: { 386 | body: { 387 | content: { 388 | 'application/json': { 389 | schema: z.string(), 390 | }, 391 | 'application/xml': { 392 | schema: UserSchema, 393 | }, 394 | }, 395 | }, 396 | }, 397 | }); 398 | 399 | registry[registerFunction](route); 400 | 401 | const document = generateDocumentWithPossibleWebhooks( 402 | registry.definitions 403 | ); 404 | 405 | const requestBody = ( 406 | document[rootDocPath]?.['/']?.get?.requestBody as any 407 | ).content; 408 | 409 | expect(requestBody['application/json']).toEqual({ 410 | schema: { type: 'string' }, 411 | }); 412 | 413 | expect(requestBody['application/xml']).toEqual({ 414 | schema: { $ref: '#/components/schemas/User' }, 415 | }); 416 | }); 417 | 418 | it('can automatically register request body data', () => { 419 | const Person = z 420 | .object({ 421 | name: z.string(), 422 | }) 423 | .openapi('Person'); 424 | 425 | const { documentSchemas, requestBody } = generateDataForRoute({ 426 | request: { 427 | body: { content: { 'application/json': { schema: Person } } }, 428 | }, 429 | }); 430 | 431 | expect(documentSchemas).toEqual({ 432 | Person: { 433 | type: 'object', 434 | properties: { 435 | name: { type: 'string' }, 436 | }, 437 | required: ['name'], 438 | }, 439 | }); 440 | 441 | expect(requestBody).toEqual({ 442 | content: { 443 | 'application/json': { 444 | schema: { $ref: '#/components/schemas/Person' }, 445 | }, 446 | }, 447 | }); 448 | }); 449 | }); 450 | }; 451 | 452 | describe.each` 453 | type | registerFunction | rootDocPath 454 | ${'Routes'} | ${'registerPath'} | ${'paths'} 455 | ${'Webhooks'} | ${'registerWebhook'} | ${'webhooks'} 456 | `('$type', routeTests); 457 | -------------------------------------------------------------------------------- /spec/routes/parameters.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { MissingParameterDataError } from '../../src/errors'; 3 | import { generateDataForRoute, registerParameter } from '../lib/helpers'; 4 | 5 | describe('parameters', () => { 6 | describe('query', () => { 7 | it('generates a query parameter for route', () => { 8 | const { parameters } = generateDataForRoute({ 9 | request: { query: z.object({ test: z.string() }) }, 10 | }); 11 | 12 | expect(parameters).toEqual([ 13 | { 14 | in: 'query', 15 | name: 'test', 16 | required: true, 17 | schema: { 18 | type: 'string', 19 | }, 20 | }, 21 | ]); 22 | }); 23 | 24 | it('generates query parameter for route from object with effects', () => { 25 | const { parameters } = generateDataForRoute({ 26 | request: { 27 | query: z 28 | .object({ 29 | filter: z.string(), 30 | }) 31 | .refine(({ filter }) => filter.length > 3) 32 | .transform(d => d), 33 | }, 34 | }); 35 | 36 | expect(parameters).toEqual([ 37 | { 38 | in: 'query', 39 | name: 'filter', 40 | required: true, 41 | schema: { 42 | type: 'string', 43 | }, 44 | }, 45 | ]); 46 | }); 47 | 48 | it('generates a query parameter for route', () => { 49 | const { parameters } = generateDataForRoute({ 50 | request: { query: z.object({ test: z.string() }) }, 51 | }); 52 | 53 | expect(parameters).toEqual([ 54 | { 55 | in: 'query', 56 | name: 'test', 57 | required: true, 58 | schema: { 59 | type: 'string', 60 | }, 61 | }, 62 | ]); 63 | }); 64 | 65 | it('generates a reference query parameter for route', () => { 66 | const TestQuery = registerParameter( 67 | 'TestQuery', 68 | z.string().openapi({ 69 | param: { name: 'test', in: 'query' }, 70 | }) 71 | ); 72 | 73 | const { parameters, documentParameters } = generateDataForRoute( 74 | { request: { query: z.object({ test: TestQuery.schema }) } }, 75 | [TestQuery] 76 | ); 77 | 78 | expect(documentParameters).toEqual({ 79 | TestQuery: { 80 | in: 'query', 81 | name: 'test', 82 | required: true, 83 | schema: { 84 | $ref: '#/components/schemas/TestQuery', 85 | }, 86 | }, 87 | }); 88 | 89 | expect(parameters).toEqual([ 90 | { $ref: '#/components/parameters/TestQuery' }, 91 | ]); 92 | }); 93 | 94 | it('can automatically register request query parameters', () => { 95 | const Person = z.object({ name: z.string() }).openapi('Person'); 96 | 97 | const { documentSchemas, parameters } = generateDataForRoute({ 98 | request: { 99 | query: z.object({ 100 | person: Person, 101 | }), 102 | }, 103 | }); 104 | 105 | expect(documentSchemas).toEqual({ 106 | Person: { 107 | type: 'object', 108 | properties: { 109 | name: { type: 'string' }, 110 | }, 111 | required: ['name'], 112 | }, 113 | }); 114 | 115 | expect(parameters).toEqual([ 116 | { 117 | in: 'query', 118 | name: 'person', 119 | required: true, 120 | schema: { 121 | $ref: '#/components/schemas/Person', 122 | }, 123 | }, 124 | ]); 125 | }); 126 | }); 127 | 128 | describe('path', () => { 129 | it('generates a path parameter for route', () => { 130 | const { parameters } = generateDataForRoute({ 131 | request: { params: z.object({ test: z.string() }) }, 132 | }); 133 | 134 | expect(parameters).toEqual([ 135 | { 136 | in: 'path', 137 | name: 'test', 138 | required: true, 139 | schema: { 140 | type: 'string', 141 | }, 142 | }, 143 | ]); 144 | }); 145 | 146 | it('generates path parameter for route from object with effects', () => { 147 | const { parameters } = generateDataForRoute({ 148 | request: { 149 | params: z 150 | .object({ 151 | filter: z.string(), 152 | }) 153 | .refine(({ filter }) => filter.length > 3) 154 | .transform(d => d), 155 | }, 156 | }); 157 | 158 | expect(parameters).toEqual([ 159 | { 160 | in: 'path', 161 | name: 'filter', 162 | required: true, 163 | schema: { 164 | type: 'string', 165 | }, 166 | }, 167 | ]); 168 | }); 169 | 170 | it('generates a reference path parameter for route', () => { 171 | const TestParam = registerParameter( 172 | 'TestParam', 173 | z.string().openapi({ 174 | param: { name: 'test', in: 'path' }, 175 | }) 176 | ); 177 | 178 | const { parameters, documentParameters } = generateDataForRoute( 179 | { request: { params: z.object({ test: TestParam.schema }) } }, 180 | [TestParam] 181 | ); 182 | 183 | expect(documentParameters).toEqual({ 184 | TestParam: { 185 | in: 'path', 186 | name: 'test', 187 | required: true, 188 | schema: { 189 | $ref: '#/components/schemas/TestParam', 190 | }, 191 | }, 192 | }); 193 | 194 | expect(parameters).toEqual([ 195 | { $ref: '#/components/parameters/TestParam' }, 196 | ]); 197 | }); 198 | 199 | it('can automatically register request path parameters', () => { 200 | const UserId = z.string().openapi('UserId').length(6); 201 | 202 | const { documentSchemas, parameters } = generateDataForRoute({ 203 | request: { 204 | params: z.object({ 205 | id: UserId, 206 | }), 207 | }, 208 | }); 209 | 210 | expect(documentSchemas).toEqual({ 211 | UserId: { 212 | type: 'string', 213 | minLength: 6, 214 | maxLength: 6, 215 | }, 216 | }); 217 | 218 | expect(parameters).toEqual([ 219 | { 220 | in: 'path', 221 | name: 'id', 222 | required: true, 223 | schema: { 224 | $ref: '#/components/schemas/UserId', 225 | }, 226 | }, 227 | ]); 228 | }); 229 | }); 230 | 231 | describe('cookies', () => { 232 | it('generates a cookie parameter for route', () => { 233 | const { parameters } = generateDataForRoute({ 234 | request: { cookies: z.object({ test: z.string() }) }, 235 | }); 236 | 237 | expect(parameters).toEqual([ 238 | { 239 | in: 'cookie', 240 | name: 'test', 241 | required: true, 242 | schema: { 243 | type: 'string', 244 | }, 245 | }, 246 | ]); 247 | }); 248 | 249 | it('generates cookie parameter for route from object with effects', () => { 250 | const { parameters } = generateDataForRoute({ 251 | request: { 252 | cookies: z 253 | .object({ 254 | filter: z.string(), 255 | }) 256 | .refine(({ filter }) => filter.length > 3) 257 | .transform(d => d), 258 | }, 259 | }); 260 | 261 | expect(parameters).toEqual([ 262 | { 263 | in: 'cookie', 264 | name: 'filter', 265 | required: true, 266 | schema: { 267 | type: 'string', 268 | }, 269 | }, 270 | ]); 271 | }); 272 | 273 | it('generates a reference cookie parameter for route', () => { 274 | const TestParam = registerParameter( 275 | 'TestParam', 276 | z.string().openapi({ 277 | param: { name: 'test', in: 'cookie' }, 278 | }) 279 | ); 280 | 281 | const { parameters, documentParameters } = generateDataForRoute( 282 | { request: { cookies: z.object({ test: TestParam.schema }) } }, 283 | [TestParam] 284 | ); 285 | 286 | expect(documentParameters).toEqual({ 287 | TestParam: { 288 | in: 'cookie', 289 | name: 'test', 290 | required: true, 291 | schema: { 292 | $ref: '#/components/schemas/TestParam', 293 | }, 294 | }, 295 | }); 296 | 297 | expect(parameters).toEqual([ 298 | { $ref: '#/components/parameters/TestParam' }, 299 | ]); 300 | }); 301 | 302 | it('can automatically register request cookie parameters', () => { 303 | const cookieId = z.string().openapi('cookieId').length(6); 304 | 305 | const { documentSchemas, parameters } = generateDataForRoute({ 306 | request: { 307 | cookies: z.object({ 308 | id: cookieId, 309 | }), 310 | }, 311 | }); 312 | 313 | expect(documentSchemas).toEqual({ 314 | cookieId: { 315 | type: 'string', 316 | minLength: 6, 317 | maxLength: 6, 318 | }, 319 | }); 320 | 321 | expect(parameters).toEqual([ 322 | { 323 | in: 'cookie', 324 | name: 'id', 325 | required: true, 326 | schema: { 327 | $ref: '#/components/schemas/cookieId', 328 | }, 329 | }, 330 | ]); 331 | }); 332 | }); 333 | 334 | describe('header', () => { 335 | it('generates a header parameter with array for route', () => { 336 | const { parameters } = generateDataForRoute({ 337 | request: { 338 | headers: z.object({ test: z.string() }), 339 | }, 340 | }); 341 | 342 | expect(parameters).toEqual([ 343 | { 344 | in: 'header', 345 | name: 'test', 346 | required: true, 347 | schema: { 348 | type: 'string', 349 | }, 350 | }, 351 | ]); 352 | }); 353 | 354 | it('generates a header parameter with object for route', () => { 355 | const { parameters } = generateDataForRoute({ 356 | request: { 357 | headers: [z.string().openapi({ param: { name: 'test' } })], 358 | }, 359 | }); 360 | 361 | expect(parameters).toEqual([ 362 | { 363 | in: 'header', 364 | name: 'test', 365 | required: true, 366 | schema: { 367 | type: 'string', 368 | }, 369 | }, 370 | ]); 371 | }); 372 | 373 | it('generates header parameter for route from object with effects', () => { 374 | const { parameters } = generateDataForRoute({ 375 | request: { 376 | headers: z 377 | .object({ 378 | filter: z.string(), 379 | }) 380 | .refine(({ filter }) => filter.length > 3) 381 | .transform(d => d), 382 | }, 383 | }); 384 | 385 | expect(parameters).toEqual([ 386 | { 387 | in: 'header', 388 | name: 'filter', 389 | required: true, 390 | schema: { 391 | type: 'string', 392 | }, 393 | }, 394 | ]); 395 | }); 396 | 397 | it('generates a reference header parameter for route', () => { 398 | const TestHeader = registerParameter( 399 | 'TestHeader', 400 | z.string().openapi({ 401 | param: { name: 'test', in: 'header' }, 402 | }) 403 | ); 404 | 405 | const { parameters, documentParameters } = generateDataForRoute( 406 | { request: { headers: [TestHeader.schema] } }, 407 | [TestHeader] 408 | ); 409 | 410 | expect(documentParameters).toEqual({ 411 | TestHeader: { 412 | in: 'header', 413 | name: 'test', 414 | required: true, 415 | schema: { 416 | $ref: '#/components/schemas/TestHeader', 417 | }, 418 | }, 419 | }); 420 | 421 | expect(parameters).toEqual([ 422 | { $ref: '#/components/parameters/TestHeader' }, 423 | ]); 424 | }); 425 | 426 | it('can automatically register request header parameters', () => { 427 | const SessionToken = z.string().openapi('SessionToken').length(6); 428 | 429 | const { documentSchemas, parameters } = generateDataForRoute({ 430 | request: { 431 | headers: z.object({ 432 | 'x-session': SessionToken, 433 | }), 434 | }, 435 | }); 436 | 437 | expect(documentSchemas).toEqual({ 438 | SessionToken: { 439 | type: 'string', 440 | minLength: 6, 441 | maxLength: 6, 442 | }, 443 | }); 444 | 445 | expect(parameters).toEqual([ 446 | { 447 | in: 'header', 448 | name: 'x-session', 449 | required: true, 450 | schema: { 451 | $ref: '#/components/schemas/SessionToken', 452 | }, 453 | }, 454 | ]); 455 | }); 456 | }); 457 | 458 | it('combines parameter definitions', () => { 459 | const { parameters } = generateDataForRoute({ 460 | request: { 461 | query: z.object({ request_queryId: z.string() }), 462 | params: z.object({ request_paramsId: z.string() }), 463 | }, 464 | parameters: [ 465 | { in: 'query', name: 'params_queryId' }, 466 | { in: 'path', name: 'params_pathId' }, 467 | ], 468 | }); 469 | 470 | expect(parameters).toEqual([ 471 | { in: 'query', name: 'params_queryId' }, 472 | { in: 'path', name: 'params_pathId' }, 473 | { 474 | schema: { type: 'string' }, 475 | required: true, 476 | name: 'request_paramsId', 477 | in: 'path', 478 | }, 479 | { 480 | schema: { type: 'string' }, 481 | required: true, 482 | name: 'request_queryId', 483 | in: 'query', 484 | }, 485 | ]); 486 | }); 487 | 488 | it('generates required based on inner schema', () => { 489 | const { parameters } = generateDataForRoute({ 490 | request: { 491 | query: z.object({ test: z.string().optional().default('test') }), 492 | }, 493 | }); 494 | 495 | expect(parameters).toEqual([ 496 | { 497 | in: 'query', 498 | name: 'test', 499 | required: false, 500 | schema: { 501 | type: 'string', 502 | default: 'test', 503 | }, 504 | }, 505 | ]); 506 | }); 507 | 508 | it('supports strict zod objects', () => { 509 | const { parameters } = generateDataForRoute({ 510 | request: { 511 | query: z.strictObject({ 512 | test: z.string().optional().default('test'), 513 | }), 514 | }, 515 | }); 516 | 517 | expect(parameters).toEqual([ 518 | { 519 | in: 'query', 520 | name: 'test', 521 | required: false, 522 | schema: { 523 | type: 'string', 524 | default: 'test', 525 | }, 526 | }, 527 | ]); 528 | }); 529 | 530 | describe('errors', () => { 531 | it('throws an error in case of names mismatch', () => { 532 | expect(() => 533 | generateDataForRoute({ 534 | request: { 535 | query: z.object({ 536 | test: z.string().openapi({ param: { name: 'another' } }), 537 | }), 538 | }, 539 | }) 540 | ).toThrowError(/^Conflicting name/); 541 | }); 542 | 543 | it('throws an error in case of location mismatch', () => { 544 | expect(() => 545 | generateDataForRoute({ 546 | request: { 547 | query: z.object({ 548 | test: z.string().openapi({ param: { in: 'header' } }), 549 | }), 550 | }, 551 | }) 552 | ).toThrowError(/^Conflicting location/); 553 | }); 554 | 555 | it('throws an error in case of location mismatch with reference', () => { 556 | const TestHeader = registerParameter( 557 | 'TestHeader', 558 | z.string().openapi({ 559 | param: { name: 'test', in: 'header' }, 560 | }) 561 | ); 562 | 563 | expect(() => 564 | generateDataForRoute( 565 | { 566 | request: { query: z.object({ test: TestHeader.schema }) }, 567 | }, 568 | [TestHeader] 569 | ) 570 | ).toThrowError(/^Conflicting location/); 571 | }); 572 | 573 | it('throws an error in case of name mismatch with reference', () => { 574 | const TestQuery = registerParameter( 575 | 'TestQuery', 576 | z.string().openapi({ 577 | param: { name: 'test', in: 'query' }, 578 | }) 579 | ); 580 | 581 | expect(() => 582 | generateDataForRoute( 583 | { 584 | request: { query: z.object({ randomName: TestQuery.schema }) }, 585 | }, 586 | [TestQuery] 587 | ) 588 | ).toThrowError(/^Conflicting name/); 589 | }); 590 | 591 | it('throws an error in case of missing name', () => { 592 | try { 593 | generateDataForRoute({ 594 | method: 'get', 595 | path: '/path', 596 | request: { headers: [z.string()] }, 597 | }); 598 | 599 | expect("Should've thrown").toEqual('Did throw'); 600 | } catch (error) { 601 | expect(error).toBeInstanceOf(MissingParameterDataError); 602 | expect(error).toHaveProperty('data.location', 'header'); 603 | expect(error).toHaveProperty('data.route', 'get /path'); 604 | } 605 | }); 606 | 607 | it('throws an error in case of missing location when registering a parameter', () => { 608 | const TestQuery = registerParameter( 609 | 'TestQuery', 610 | z.string().openapi({ 611 | param: { name: 'test' }, 612 | }) 613 | ); 614 | 615 | expect(() => generateDataForRoute({}, [TestQuery])).toThrowError( 616 | /^Missing parameter data, please specify `in`/ 617 | ); 618 | }); 619 | }); 620 | }); 621 | -------------------------------------------------------------------------------- /spec/separate-zod-instance.spec.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from '../src/zod-extensions'; 2 | import { expectSchema } from './lib/helpers'; 3 | 4 | /** 5 | * See https://github.com/asteasolutions/zod-to-openapi/issues/17 6 | */ 7 | describe('Separate Zod instance', () => { 8 | function requireSeparateZodInstance() { 9 | jest.resetModules(); 10 | delete require.cache[require.resolve('zod')]; 11 | 12 | return require('zod'); 13 | } 14 | 15 | const zod1 = requireSeparateZodInstance(); 16 | extendZodWithOpenApi(zod1); 17 | 18 | const zod2 = requireSeparateZodInstance(); 19 | extendZodWithOpenApi(zod2); 20 | 21 | it('can check object types of different zod instances', () => { 22 | expectSchema([zod1.string().openapi('SimpleString')], { 23 | SimpleString: { type: 'string' }, 24 | }); 25 | 26 | expectSchema([zod2.string().openapi('SimpleString')], { 27 | SimpleString: { type: 'string' }, 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /spec/setup-tests.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { extendZodWithOpenApi } from '../src/zod-extensions'; 3 | 4 | extendZodWithOpenApi(z); 5 | -------------------------------------------------------------------------------- /spec/types/any.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | // Based on the "Any Type" section of https://swagger.io/docs/specification/data-models/data-types/ 5 | 6 | describe('any', () => { 7 | it('supports any for 3.0.0 ', () => { 8 | expectSchema([z.any().openapi('Any', { description: 'Something' })], { 9 | Any: { description: 'Something', nullable: true }, 10 | }); 11 | }); 12 | 13 | it('supports any for 3.1.0', () => { 14 | expectSchema( 15 | [z.any().openapi('Any', { description: 'Something' })], 16 | { 17 | Any: { description: 'Something' }, 18 | }, 19 | '3.1.0' 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /spec/types/array.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('array', () => { 5 | it('supports arrays of strings', () => { 6 | expectSchema([z.array(z.string()).openapi('Array')], { 7 | Array: { 8 | type: 'array', 9 | items: { type: 'string' }, 10 | }, 11 | }); 12 | }); 13 | 14 | it('supports minLength / maxLength on arrays', () => { 15 | expectSchema([z.array(z.string()).min(5).max(10).openapi('Array')], { 16 | Array: { 17 | type: 'array', 18 | items: { type: 'string' }, 19 | minItems: 5, 20 | maxItems: 10, 21 | }, 22 | }); 23 | }); 24 | 25 | it('can automatically register array items', () => { 26 | const schema = z.array(z.string().openapi('StringId')).openapi('Array'); 27 | 28 | expectSchema([schema], { 29 | StringId: { 30 | type: 'string', 31 | }, 32 | 33 | Array: { 34 | type: 'array', 35 | items: { 36 | $ref: '#/components/schemas/StringId', 37 | }, 38 | }, 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /spec/types/bigint.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('bigint', () => { 5 | it('generates OpenAPI schema for a simple bigint type', () => { 6 | expectSchema([z.bigint().openapi('SimpleBigInt')], { 7 | SimpleBigInt: { type: 'string', pattern: `^\d+$` }, 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /spec/types/date.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('date', () => { 5 | it('supports ZodDate and sets the type to `string`', () => { 6 | const schema = z.date().openapi('Date'); 7 | 8 | expectSchema([schema], { 9 | Date: { 10 | type: 'string', 11 | }, 12 | }); 13 | }); 14 | 15 | it('uses `string` as the example type when the schema infers to `Date`', () => { 16 | const example = new Date().toISOString(); 17 | const schema = z.date().openapi('Date', { example }); 18 | 19 | expectSchema([schema], { 20 | Date: { 21 | type: 'string', 22 | example, 23 | }, 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /spec/types/discriminated-union.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('discriminated union', () => { 5 | it('supports basic discriminated unions', () => { 6 | const Text = z.object({ type: z.literal('text'), text: z.string() }); 7 | const Image = z.object({ type: z.literal('image'), src: z.string() }); 8 | 9 | expectSchema( 10 | [z.discriminatedUnion('type', [Text, Image]).openapi('Test')], 11 | { 12 | Test: { 13 | oneOf: [ 14 | { 15 | type: 'object', 16 | required: ['type', 'text'], 17 | properties: { 18 | type: { type: 'string', enum: ['text'] }, 19 | text: { type: 'string' }, 20 | }, 21 | }, 22 | { 23 | type: 'object', 24 | required: ['type', 'src'], 25 | properties: { 26 | type: { type: 'string', enum: ['image'] }, 27 | src: { type: 'string' }, 28 | }, 29 | }, 30 | ], 31 | }, 32 | } 33 | ); 34 | }); 35 | 36 | it('creates a discriminator mapping when all objects in the discriminated union contain a registered schema', () => { 37 | const Text = z 38 | .object({ type: z.literal('text'), text: z.string() }) 39 | .openapi('obj1'); 40 | const Image = z 41 | .object({ type: z.literal('image'), src: z.string() }) 42 | .openapi('obj2'); 43 | 44 | expectSchema( 45 | [ 46 | Text, 47 | Image, 48 | z.discriminatedUnion('type', [Text, Image]).openapi('Test'), 49 | ], 50 | { 51 | Test: { 52 | oneOf: [ 53 | { $ref: '#/components/schemas/obj1' }, 54 | { $ref: '#/components/schemas/obj2' }, 55 | ], 56 | discriminator: { 57 | propertyName: 'type', 58 | mapping: { 59 | text: '#/components/schemas/obj1', 60 | image: '#/components/schemas/obj2', 61 | }, 62 | }, 63 | }, 64 | obj1: { 65 | type: 'object', 66 | required: ['type', 'text'], 67 | properties: { 68 | type: { type: 'string', enum: ['text'] }, 69 | text: { type: 'string' }, 70 | }, 71 | }, 72 | obj2: { 73 | type: 'object', 74 | required: ['type', 'src'], 75 | properties: { 76 | type: { type: 'string', enum: ['image'] }, 77 | src: { type: 'string' }, 78 | }, 79 | }, 80 | } 81 | ); 82 | }); 83 | 84 | it('creates a discriminator mapping when a registered object uses a zodEnum as the discriminator', () => { 85 | const Text = z 86 | .object({ type: z.enum(['text', 'other']), text: z.string() }) 87 | .openapi('obj1'); 88 | const Image = z 89 | .object({ type: z.literal('image'), src: z.string() }) 90 | .openapi('obj2'); 91 | 92 | expectSchema( 93 | [ 94 | Text, 95 | Image, 96 | z.discriminatedUnion('type', [Text, Image]).openapi('Test'), 97 | ], 98 | { 99 | Test: { 100 | oneOf: [ 101 | { $ref: '#/components/schemas/obj1' }, 102 | { $ref: '#/components/schemas/obj2' }, 103 | ], 104 | discriminator: { 105 | propertyName: 'type', 106 | mapping: { 107 | text: '#/components/schemas/obj1', 108 | other: '#/components/schemas/obj1', 109 | image: '#/components/schemas/obj2', 110 | }, 111 | }, 112 | }, 113 | obj1: { 114 | type: 'object', 115 | required: ['type', 'text'], 116 | properties: { 117 | type: { type: 'string', enum: ['text', 'other'] }, 118 | text: { type: 'string' }, 119 | }, 120 | }, 121 | obj2: { 122 | type: 'object', 123 | required: ['type', 'src'], 124 | properties: { 125 | type: { type: 'string', enum: ['image'] }, 126 | src: { type: 'string' }, 127 | }, 128 | }, 129 | } 130 | ); 131 | }); 132 | 133 | it('does not create a discriminator mapping when the discrimnated union is nullable', () => { 134 | const Text = z 135 | .object({ type: z.literal('text'), text: z.string() }) 136 | .openapi('obj1'); 137 | const Image = z 138 | .object({ type: z.literal('image'), src: z.string() }) 139 | .openapi('obj2'); 140 | 141 | expectSchema( 142 | [ 143 | Text, 144 | Image, 145 | z.discriminatedUnion('type', [Text, Image]).nullable().openapi('Test'), 146 | ], 147 | { 148 | Test: { 149 | oneOf: [ 150 | { $ref: '#/components/schemas/obj1' }, 151 | { $ref: '#/components/schemas/obj2' }, 152 | { nullable: true }, 153 | ], 154 | }, 155 | obj1: { 156 | type: 'object', 157 | required: ['type', 'text'], 158 | properties: { 159 | type: { type: 'string', enum: ['text'] }, 160 | text: { type: 'string' }, 161 | }, 162 | }, 163 | obj2: { 164 | type: 'object', 165 | required: ['type', 'src'], 166 | properties: { 167 | type: { type: 'string', enum: ['image'] }, 168 | src: { type: 'string' }, 169 | }, 170 | }, 171 | } 172 | ); 173 | }); 174 | 175 | it('does not create a discriminator mapping when only some objects in the discriminated union contain a registered schema', () => { 176 | const Text = z 177 | .object({ type: z.literal('text'), text: z.string() }) 178 | .openapi('obj1'); 179 | const Image = z.object({ type: z.literal('image'), src: z.string() }); 180 | 181 | expectSchema( 182 | [ 183 | Text, 184 | Image, 185 | z.discriminatedUnion('type', [Text, Image]).openapi('Test'), 186 | ], 187 | { 188 | Test: { 189 | oneOf: [ 190 | { $ref: '#/components/schemas/obj1' }, 191 | { 192 | type: 'object', 193 | required: ['type', 'src'], 194 | properties: { 195 | type: { type: 'string', enum: ['image'] }, 196 | src: { type: 'string' }, 197 | }, 198 | }, 199 | ], 200 | }, 201 | obj1: { 202 | type: 'object', 203 | required: ['type', 'text'], 204 | properties: { 205 | type: { type: 'string', enum: ['text'] }, 206 | text: { type: 'string' }, 207 | }, 208 | }, 209 | } 210 | ); 211 | }); 212 | 213 | it('can automatically register discriminated union items', () => { 214 | const schema = z 215 | .discriminatedUnion('type', [ 216 | z.object({ type: z.literal('dog').openapi('DogType') }).openapi('Dog'), 217 | z.object({ type: z.literal('cat').openapi('CatType') }), 218 | ]) 219 | .openapi('DiscriminatedUnion'); 220 | 221 | expectSchema([schema], { 222 | DogType: { 223 | type: 'string', 224 | enum: ['dog'], 225 | }, 226 | 227 | CatType: { 228 | type: 'string', 229 | enum: ['cat'], 230 | }, 231 | 232 | Dog: { 233 | type: 'object', 234 | required: ['type'], 235 | properties: { 236 | type: { $ref: '#/components/schemas/DogType' }, 237 | }, 238 | }, 239 | 240 | DiscriminatedUnion: { 241 | oneOf: [ 242 | { $ref: '#/components/schemas/Dog' }, 243 | { 244 | type: 'object', 245 | required: ['type'], 246 | properties: { 247 | type: { $ref: '#/components/schemas/CatType' }, 248 | }, 249 | }, 250 | ], 251 | }, 252 | }); 253 | }); 254 | }); 255 | -------------------------------------------------------------------------------- /spec/types/enum.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('enum', () => { 5 | it('supports enums', () => { 6 | const schema = z 7 | .enum(['option1', 'option2']) 8 | .openapi('Enum', { description: 'All possible options' }); 9 | 10 | expectSchema([schema], { 11 | Enum: { 12 | type: 'string', 13 | description: 'All possible options', 14 | enum: ['option1', 'option2'], 15 | }, 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /spec/types/intersection.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('intersection', () => { 5 | it('supports intersection types', () => { 6 | const Person = z.object({ 7 | name: z.string(), 8 | }); 9 | 10 | const Employee = z.object({ 11 | role: z.string(), 12 | }); 13 | 14 | expectSchema([z.intersection(Person, Employee).openapi('Test')], { 15 | Test: { 16 | allOf: [ 17 | { 18 | type: 'object', 19 | properties: { name: { type: 'string' } }, 20 | required: ['name'], 21 | }, 22 | { 23 | type: 'object', 24 | properties: { role: { type: 'string' } }, 25 | required: ['role'], 26 | }, 27 | ], 28 | }, 29 | }); 30 | }); 31 | 32 | it('can automatically register intersection items', () => { 33 | const Person = z 34 | .object({ 35 | name: z.string(), 36 | }) 37 | .openapi('Person'); 38 | 39 | const Employee = z.object({ 40 | role: z.string(), 41 | }); 42 | 43 | const schema = z.intersection(Person, Employee).openapi('Intersection'); 44 | 45 | expectSchema([schema], { 46 | Person: { 47 | type: 'object', 48 | properties: { 49 | name: { 50 | type: 'string', 51 | }, 52 | }, 53 | required: ['name'], 54 | }, 55 | 56 | Intersection: { 57 | allOf: [ 58 | { $ref: '#/components/schemas/Person' }, 59 | { 60 | type: 'object', 61 | properties: { role: { type: 'string' } }, 62 | required: ['role'], 63 | }, 64 | ], 65 | }, 66 | }); 67 | }); 68 | 69 | it('supports nullable intersection types', () => { 70 | const Person = z.object({ 71 | name: z.string(), 72 | }); 73 | 74 | const Employee = z.object({ 75 | role: z.string(), 76 | }); 77 | 78 | expectSchema( 79 | [z.intersection(Person, Employee).nullable().openapi('Test')], 80 | { 81 | Test: { 82 | anyOf: [ 83 | { 84 | allOf: [ 85 | { 86 | type: 'object', 87 | properties: { name: { type: 'string' } }, 88 | required: ['name'], 89 | }, 90 | { 91 | type: 'object', 92 | properties: { role: { type: 'string' } }, 93 | required: ['role'], 94 | }, 95 | ], 96 | }, 97 | { nullable: true }, 98 | ], 99 | }, 100 | } 101 | ); 102 | }); 103 | 104 | it('supports default intersection types', () => { 105 | const Person = z.object({ 106 | name: z.string(), 107 | }); 108 | 109 | const Employee = z.object({ 110 | role: z.string(), 111 | }); 112 | 113 | expectSchema( 114 | [ 115 | z 116 | .intersection(Person, Employee) 117 | .default({ name: 'hello', role: 'world' }) 118 | .openapi('Test'), 119 | ], 120 | { 121 | Test: { 122 | allOf: [ 123 | { 124 | type: 'object', 125 | properties: { name: { type: 'string' } }, 126 | required: ['name'], 127 | }, 128 | { 129 | type: 'object', 130 | properties: { role: { type: 'string' } }, 131 | required: ['role'], 132 | }, 133 | ], 134 | default: { 135 | name: 'hello', 136 | role: 'world', 137 | }, 138 | }, 139 | } 140 | ); 141 | }); 142 | 143 | it('supports nullable default intersection types', () => { 144 | const Person = z.object({ 145 | name: z.string(), 146 | }); 147 | 148 | const Employee = z.object({ 149 | role: z.string(), 150 | }); 151 | 152 | expectSchema( 153 | [ 154 | z 155 | .intersection(Person, Employee) 156 | .nullable() 157 | .default({ name: 'hello', role: 'world' }) 158 | .openapi('Test'), 159 | ], 160 | { 161 | Test: { 162 | anyOf: [ 163 | { 164 | allOf: [ 165 | { 166 | type: 'object', 167 | properties: { name: { type: 'string' } }, 168 | required: ['name'], 169 | }, 170 | { 171 | type: 'object', 172 | properties: { role: { type: 'string' } }, 173 | required: ['role'], 174 | }, 175 | ], 176 | }, 177 | { nullable: true }, 178 | ], 179 | default: { 180 | name: 'hello', 181 | role: 'world', 182 | }, 183 | }, 184 | } 185 | ); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /spec/types/native-enum.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { createSchemas, expectSchema } from '../lib/helpers'; 3 | 4 | describe('native enum', () => { 5 | it('supports native enums', () => { 6 | enum NativeEnum { 7 | OPTION = 'Option', 8 | ANOTHER = 'Another', 9 | DEFAULT = 'Default', 10 | } 11 | 12 | const nativeEnumSchema = z.nativeEnum(NativeEnum).openapi('NativeEnum', { 13 | description: 'A native enum in zod', 14 | }); 15 | 16 | expectSchema([nativeEnumSchema], { 17 | NativeEnum: { 18 | type: 'string', 19 | description: 'A native enum in zod', 20 | enum: ['Option', 'Another', 'Default'], 21 | }, 22 | }); 23 | }); 24 | 25 | it('supports native numeric enums', () => { 26 | enum NativeEnum { 27 | OPTION = 1, 28 | ANOTHER = 42, 29 | DEFAULT = 3, 30 | } 31 | 32 | const nativeEnumSchema = z.nativeEnum(NativeEnum).openapi('NativeEnum', { 33 | description: 'A native numbers enum in zod', 34 | }); 35 | 36 | expectSchema([nativeEnumSchema], { 37 | NativeEnum: { 38 | type: 'integer', 39 | description: 'A native numbers enum in zod', 40 | enum: [1, 42, 3], 41 | }, 42 | }); 43 | }); 44 | 45 | it('does not support mixed native enums', () => { 46 | enum NativeEnum { 47 | OPTION = 1, 48 | ANOTHER = '42', 49 | } 50 | 51 | const nativeEnumSchema = z.nativeEnum(NativeEnum).openapi('NativeEnum', { 52 | description: 'A native mixed enum in zod', 53 | }); 54 | 55 | expect(() => { 56 | createSchemas([nativeEnumSchema]); 57 | }).toThrowError(/Enum has mixed string and number values/); 58 | }); 59 | 60 | it('can manually set type of mixed native enums', () => { 61 | enum NativeEnum { 62 | OPTION = 1, 63 | ANOTHER = '42', 64 | } 65 | 66 | const nativeEnumSchema = z.nativeEnum(NativeEnum).openapi('NativeEnum', { 67 | description: 'A native mixed enum in zod', 68 | type: 'string', 69 | }); 70 | 71 | expectSchema([nativeEnumSchema], { 72 | NativeEnum: { 73 | type: 'string', 74 | description: 'A native mixed enum in zod', 75 | }, 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /spec/types/null.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('null', () => { 5 | it('supports null in 3.0.0', () => { 6 | const schema = z.null().openapi('Null'); 7 | 8 | expectSchema( 9 | [schema], 10 | { 11 | Null: { 12 | nullable: true, 13 | }, 14 | }, 15 | '3.0.0' 16 | ); 17 | }); 18 | 19 | it('supports null in 3.1.0', () => { 20 | const schema = z.null().openapi('Null'); 21 | 22 | expectSchema( 23 | [schema], 24 | { 25 | Null: { 26 | type: 'null', 27 | }, 28 | }, 29 | '3.1.0' 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /spec/types/number.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('number', () => { 5 | it('generates OpenAPI schema for a simple number type', () => { 6 | expectSchema([z.number().openapi('SimpleNumber')], { 7 | SimpleNumber: { type: 'number' }, 8 | }); 9 | }); 10 | 11 | it('generates OpenAPI schema for a simple integer type', () => { 12 | expectSchema([z.number().int().openapi('SimpleInteger')], { 13 | SimpleInteger: { type: 'integer' }, 14 | }); 15 | }); 16 | 17 | it('supports number literals', () => { 18 | expectSchema([z.literal(42).openapi('Literal')], { 19 | Literal: { type: 'number', enum: [42] }, 20 | }); 21 | }); 22 | 23 | it('supports minimum in open api 3.0.0', () => { 24 | expectSchema([z.number().int().gte(0).openapi('SimpleInteger')], { 25 | SimpleInteger: { type: 'integer', minimum: 0 }, 26 | }); 27 | }); 28 | 29 | it('supports exclusive minimum in open api 3.0.0', () => { 30 | expectSchema([z.number().int().gt(0).openapi('SimpleInteger')], { 31 | SimpleInteger: { 32 | type: 'integer', 33 | minimum: 0, 34 | exclusiveMinimum: true, 35 | }, 36 | }); 37 | }); 38 | 39 | it('supports maximum in open api 3.0.0', () => { 40 | expectSchema([z.number().int().lte(0).openapi('SimpleInteger')], { 41 | SimpleInteger: { type: 'integer', maximum: 0 }, 42 | }); 43 | }); 44 | 45 | it('supports exclusive maximum in open api 3.0.0', () => { 46 | expectSchema([z.number().int().lt(0).openapi('SimpleInteger')], { 47 | SimpleInteger: { 48 | type: 'integer', 49 | maximum: 0, 50 | exclusiveMaximum: true, 51 | }, 52 | }); 53 | }); 54 | 55 | it('supports minimum in open api 3.1.0', () => { 56 | expectSchema( 57 | [z.number().int().gte(0).openapi('SimpleInteger')], 58 | { 59 | SimpleInteger: { type: 'integer', minimum: 0 }, 60 | }, 61 | '3.1.0' 62 | ); 63 | }); 64 | 65 | it('supports exclusive minimum in open api 3.1.0', () => { 66 | expectSchema( 67 | [z.number().int().gt(0).openapi('SimpleInteger')], 68 | { 69 | SimpleInteger: { type: 'integer', exclusiveMinimum: 0 } as never, 70 | }, 71 | '3.1.0' 72 | ); 73 | }); 74 | 75 | it('supports maximum in open api 3.1.0', () => { 76 | expectSchema( 77 | [z.number().int().lte(0).openapi('SimpleInteger')], 78 | { 79 | SimpleInteger: { type: 'integer', maximum: 0 }, 80 | }, 81 | '3.1.0' 82 | ); 83 | }); 84 | 85 | it('supports exclusive maximum in open api 3.1.0', () => { 86 | expectSchema( 87 | [z.number().int().lt(0).openapi('SimpleInteger')], 88 | { 89 | SimpleInteger: { type: 'integer', exclusiveMaximum: 0 } as never, 90 | }, 91 | '3.1.0' 92 | ); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /spec/types/object-polymorphism.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('object polymorphism', () => { 5 | it('can use allOf for extended schemas', () => { 6 | const BaseSchema = z.object({ id: z.string() }).openapi('Base'); 7 | const ExtendedSchema = BaseSchema.extend({ bonus: z.number() }).openapi( 8 | 'Extended' 9 | ); 10 | 11 | expectSchema([BaseSchema, ExtendedSchema], { 12 | Base: { 13 | type: 'object', 14 | required: ['id'], 15 | properties: { 16 | id: { type: 'string' }, 17 | }, 18 | }, 19 | Extended: { 20 | allOf: [ 21 | { $ref: '#/components/schemas/Base' }, 22 | { 23 | type: 'object', 24 | required: ['bonus'], 25 | properties: { 26 | bonus: { type: 'number' }, 27 | }, 28 | }, 29 | ], 30 | }, 31 | }); 32 | }); 33 | 34 | it('can chain-extend objects correctly', () => { 35 | const BaseSchema = z.object({ id: z.string() }).openapi('Base'); 36 | 37 | const A = BaseSchema.extend({ 38 | bonus: z.number(), 39 | }).openapi('A'); 40 | 41 | const B = A.extend({ 42 | points: z.number(), 43 | }).openapi('B'); 44 | 45 | expectSchema([BaseSchema, A, B], { 46 | Base: { 47 | type: 'object', 48 | required: ['id'], 49 | properties: { 50 | id: { type: 'string' }, 51 | }, 52 | }, 53 | A: { 54 | allOf: [ 55 | { $ref: '#/components/schemas/Base' }, 56 | { 57 | type: 'object', 58 | required: ['bonus'], 59 | properties: { 60 | bonus: { 61 | type: 'number', 62 | }, 63 | }, 64 | }, 65 | ], 66 | }, 67 | B: { 68 | allOf: [ 69 | { $ref: '#/components/schemas/A' }, 70 | { 71 | type: 'object', 72 | required: ['points'], 73 | properties: { 74 | points: { 75 | type: 'number', 76 | }, 77 | }, 78 | }, 79 | ], 80 | }, 81 | }); 82 | }); 83 | 84 | it('can chain-extend objects correctly without intermediate link', () => { 85 | const BaseSchema = z.object({ id: z.string() }).openapi('Base'); 86 | const A = BaseSchema.extend({ bonus: z.number() }); 87 | 88 | const B = A.extend({ 89 | points: z.number(), 90 | }).openapi('B'); 91 | 92 | expectSchema([BaseSchema, B], { 93 | Base: { 94 | type: 'object', 95 | required: ['id'], 96 | properties: { 97 | id: { type: 'string' }, 98 | }, 99 | }, 100 | B: { 101 | allOf: [ 102 | { $ref: '#/components/schemas/Base' }, 103 | { 104 | type: 'object', 105 | required: ['bonus', 'points'], 106 | properties: { 107 | bonus: { type: 'number' }, 108 | points: { type: 'number' }, 109 | }, 110 | }, 111 | ], 112 | }, 113 | }); 114 | }); 115 | 116 | it('can apply nullable', () => { 117 | const BaseSchema = z.object({ id: z.ostring() }).openapi('Base'); 118 | const ExtendedSchema = BaseSchema.extend({ 119 | bonus: z.onumber(), 120 | }) 121 | .nullable() 122 | .openapi('Extended'); 123 | 124 | expectSchema([BaseSchema, ExtendedSchema], { 125 | Base: { 126 | type: 'object', 127 | properties: { 128 | id: { type: 'string' }, 129 | }, 130 | }, 131 | Extended: { 132 | allOf: [ 133 | { $ref: '#/components/schemas/Base' }, 134 | { 135 | type: 'object', 136 | properties: { 137 | bonus: { type: 'number' }, 138 | }, 139 | nullable: true, 140 | }, 141 | ], 142 | }, 143 | }); 144 | }); 145 | 146 | it('can override properties', () => { 147 | const AnimalSchema = z 148 | .object({ 149 | name: z.ostring(), 150 | type: z.enum(['dog', 'cat']).optional(), 151 | }) 152 | .openapi('Animal', { 153 | discriminator: { 154 | propertyName: 'type', 155 | }, 156 | }); 157 | 158 | const DogSchema = AnimalSchema.extend({ 159 | type: z.string().openapi({ example: 'dog' }), 160 | }) 161 | .openapi('Dog', { 162 | discriminator: { 163 | propertyName: 'type', 164 | }, 165 | }) 166 | .optional(); 167 | 168 | expectSchema([AnimalSchema, DogSchema], { 169 | Animal: { 170 | discriminator: { 171 | propertyName: 'type', 172 | }, 173 | type: 'object', 174 | properties: { 175 | name: { type: 'string' }, 176 | type: { type: 'string', enum: ['dog', 'cat'] }, 177 | }, 178 | }, 179 | Dog: { 180 | discriminator: { 181 | propertyName: 'type', 182 | }, 183 | allOf: [ 184 | { $ref: '#/components/schemas/Animal' }, 185 | { 186 | type: 'object', 187 | properties: { 188 | type: { type: 'string', example: 'dog' }, 189 | }, 190 | required: ['type'], 191 | }, 192 | ], 193 | }, 194 | }); 195 | }); 196 | 197 | it('treats objects created by .omit as a new object', () => { 198 | const BaseSchema = z 199 | .object({ 200 | name: z.string(), 201 | type: z.enum(['dog', 'cat']).optional(), 202 | }) 203 | .openapi('Base'); 204 | const OmittedSchema = BaseSchema.omit({ type: true }); 205 | 206 | const OtherSchema = z.object({ omit: OmittedSchema }).openapi('Other'); 207 | expectSchema([BaseSchema, OtherSchema], { 208 | Base: { 209 | properties: { 210 | name: { type: 'string' }, 211 | type: { 212 | enum: ['dog', 'cat'], 213 | type: 'string', 214 | }, 215 | }, 216 | required: ['name'], 217 | type: 'object', 218 | }, 219 | Other: { 220 | properties: { 221 | omit: { 222 | properties: { 223 | name: { type: 'string' }, 224 | }, 225 | required: ['name'], 226 | type: 'object', 227 | }, 228 | }, 229 | required: ['omit'], 230 | type: 'object', 231 | }, 232 | }); 233 | }); 234 | 235 | it('treats objects created by .pick as a new object', () => { 236 | const BaseSchema = z 237 | .object({ 238 | name: z.string(), 239 | type: z.enum(['dog', 'cat']).optional(), 240 | }) 241 | .openapi('Base'); 242 | const PickedSchema = BaseSchema.pick({ name: true }); 243 | 244 | const OtherSchema = z.object({ pick: PickedSchema }).openapi('Other'); 245 | 246 | expectSchema([BaseSchema, OtherSchema], { 247 | Base: { 248 | properties: { 249 | name: { type: 'string' }, 250 | type: { 251 | enum: ['dog', 'cat'], 252 | type: 'string', 253 | }, 254 | }, 255 | required: ['name'], 256 | type: 'object', 257 | }, 258 | Other: { 259 | properties: { 260 | pick: { 261 | properties: { 262 | name: { type: 'string' }, 263 | }, 264 | required: ['name'], 265 | type: 'object', 266 | }, 267 | }, 268 | required: ['pick'], 269 | type: 'object', 270 | }, 271 | }); 272 | }); 273 | }); 274 | -------------------------------------------------------------------------------- /spec/types/object.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('object', () => { 5 | it('generates OpenAPI schema for nested objects', () => { 6 | expectSchema( 7 | [ 8 | z 9 | .object({ 10 | test: z.object({ 11 | id: z.string().openapi({ description: 'The entity id' }), 12 | }), 13 | }) 14 | .openapi('NestedObject'), 15 | ], 16 | { 17 | NestedObject: { 18 | type: 'object', 19 | required: ['test'], 20 | properties: { 21 | test: { 22 | type: 'object', 23 | required: ['id'], 24 | properties: { 25 | id: { type: 'string', description: 'The entity id' }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | } 31 | ); 32 | }); 33 | 34 | it('creates separate schemas and links them', () => { 35 | const SimpleStringSchema = z.string().openapi('SimpleString'); 36 | 37 | const ObjectWithStringsSchema = z 38 | .object({ 39 | str1: SimpleStringSchema.optional(), 40 | str2: SimpleStringSchema, 41 | }) 42 | .openapi('ObjectWithStrings'); 43 | 44 | expectSchema([SimpleStringSchema, ObjectWithStringsSchema], { 45 | SimpleString: { type: 'string' }, 46 | ObjectWithStrings: { 47 | type: 'object', 48 | properties: { 49 | str1: { $ref: '#/components/schemas/SimpleString' }, 50 | str2: { $ref: '#/components/schemas/SimpleString' }, 51 | }, 52 | required: ['str2'], 53 | }, 54 | }); 55 | }); 56 | 57 | it('maps additionalProperties to false for strict objects', () => { 58 | expectSchema( 59 | [ 60 | z 61 | .strictObject({ 62 | test: z.string(), 63 | }) 64 | .openapi('StrictObject'), 65 | ], 66 | { 67 | StrictObject: { 68 | type: 'object', 69 | required: ['test'], 70 | additionalProperties: false, 71 | properties: { 72 | test: { 73 | type: 'string', 74 | }, 75 | }, 76 | }, 77 | } 78 | ); 79 | }); 80 | 81 | it('can automatically register object properties', () => { 82 | const schema = z 83 | .object({ key: z.string().openapi('Test') }) 84 | .openapi('Object'); 85 | 86 | expectSchema([schema], { 87 | Test: { 88 | type: 'string', 89 | }, 90 | 91 | Object: { 92 | type: 'object', 93 | properties: { 94 | key: { 95 | $ref: '#/components/schemas/Test', 96 | }, 97 | }, 98 | required: ['key'], 99 | }, 100 | }); 101 | }); 102 | 103 | it('can automatically register extended parent properties', () => { 104 | const schema = z.object({ id: z.number().openapi('NumberId') }); 105 | 106 | const extended = schema 107 | .extend({ 108 | name: z.string().openapi('Name'), 109 | }) 110 | .openapi('ExtendedObject'); 111 | 112 | expectSchema([extended], { 113 | Name: { 114 | type: 'string', 115 | }, 116 | 117 | NumberId: { 118 | type: 'number', 119 | }, 120 | 121 | ExtendedObject: { 122 | type: 'object', 123 | properties: { 124 | id: { 125 | $ref: '#/components/schemas/NumberId', 126 | }, 127 | name: { 128 | $ref: '#/components/schemas/Name', 129 | }, 130 | }, 131 | required: ['id', 'name'], 132 | }, 133 | }); 134 | }); 135 | 136 | it('can automatically register extended schemas', () => { 137 | const schema = z 138 | .object({ id: z.string().openapi('StringId') }) 139 | .openapi('Object'); 140 | 141 | const extended = schema 142 | .extend({ 143 | id: z.number().openapi('NumberId'), 144 | }) 145 | .openapi('ExtendedObject'); 146 | 147 | expectSchema([extended], { 148 | StringId: { 149 | type: 'string', 150 | }, 151 | 152 | NumberId: { 153 | type: 'number', 154 | }, 155 | 156 | Object: { 157 | type: 'object', 158 | properties: { 159 | id: { 160 | $ref: '#/components/schemas/StringId', 161 | }, 162 | }, 163 | required: ['id'], 164 | }, 165 | 166 | ExtendedObject: { 167 | allOf: [ 168 | { $ref: '#/components/schemas/Object' }, 169 | { 170 | type: 'object', 171 | properties: { 172 | id: { $ref: '#/components/schemas/NumberId' }, 173 | }, 174 | }, 175 | ], 176 | }, 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /spec/types/record.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('record', () => { 5 | it('supports records', () => { 6 | const base = z.object({ a: z.string() }); 7 | 8 | const record = z.record(base).openapi('Record'); 9 | 10 | expectSchema([base, record], { 11 | Record: { 12 | type: 'object', 13 | additionalProperties: { 14 | type: 'object', 15 | properties: { 16 | a: { type: 'string' }, 17 | }, 18 | required: ['a'], 19 | }, 20 | }, 21 | }); 22 | }); 23 | 24 | it('supports records with refs', () => { 25 | const base = z.object({ a: z.string() }).openapi('Base'); 26 | 27 | const record = z.record(base).openapi('Record'); 28 | 29 | expectSchema([base, record], { 30 | Base: { 31 | type: 'object', 32 | properties: { 33 | a: { type: 'string' }, 34 | }, 35 | required: ['a'], 36 | }, 37 | Record: { 38 | type: 'object', 39 | additionalProperties: { 40 | $ref: '#/components/schemas/Base', 41 | }, 42 | }, 43 | }); 44 | }); 45 | 46 | it('can automatically register record items', () => { 47 | const schema = z.record(z.number().openapi('NumberId')).openapi('Record'); 48 | 49 | expectSchema([schema], { 50 | NumberId: { 51 | type: 'number', 52 | }, 53 | 54 | Record: { 55 | type: 'object', 56 | additionalProperties: { 57 | $ref: '#/components/schemas/NumberId', 58 | }, 59 | }, 60 | }); 61 | }); 62 | 63 | describe('Enum keys', () => { 64 | it('supports records with enum keys', () => { 65 | const continents = z.enum(['EUROPE', 'AFRICA']); 66 | 67 | const countries = z.enum(['USA', 'CAN']); 68 | 69 | const countryContent = z 70 | .object({ countries: countries.array() }) 71 | .openapi('Content'); 72 | 73 | const Geography = z 74 | .record(continents, countryContent) 75 | .openapi('Geography'); 76 | 77 | expectSchema([Geography], { 78 | Content: { 79 | type: 'object', 80 | properties: { 81 | countries: { 82 | type: 'array', 83 | items: { 84 | type: 'string', 85 | enum: ['USA', 'CAN'], 86 | }, 87 | }, 88 | }, 89 | required: ['countries'], 90 | }, 91 | 92 | Geography: { 93 | type: 'object', 94 | properties: { 95 | EUROPE: { $ref: '#/components/schemas/Content' }, 96 | AFRICA: { $ref: '#/components/schemas/Content' }, 97 | }, 98 | }, 99 | }); 100 | }); 101 | 102 | it('supports records with native enum keys', () => { 103 | enum Continents { 104 | EUROPE, 105 | AFRICA, 106 | } 107 | 108 | const continents = z.nativeEnum(Continents); 109 | 110 | const countries = z.enum(['USA', 'CAN']); 111 | 112 | const countryContent = z 113 | .object({ countries: countries.array() }) 114 | .openapi('Content'); 115 | 116 | const Geography = z 117 | .record(continents, countryContent) 118 | .openapi('Geography'); 119 | 120 | expectSchema([Geography], { 121 | Content: { 122 | type: 'object', 123 | properties: { 124 | countries: { 125 | type: 'array', 126 | items: { 127 | type: 'string', 128 | enum: ['USA', 'CAN'], 129 | }, 130 | }, 131 | }, 132 | required: ['countries'], 133 | }, 134 | 135 | Geography: { 136 | type: 'object', 137 | properties: { 138 | EUROPE: { $ref: '#/components/schemas/Content' }, 139 | AFRICA: { $ref: '#/components/schemas/Content' }, 140 | }, 141 | }, 142 | }); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /spec/types/string.spec.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodString } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('string', () => { 5 | it('generates OpenAPI schema for simple types', () => { 6 | expectSchema([z.string().openapi('SimpleString')], { 7 | SimpleString: { type: 'string' }, 8 | }); 9 | }); 10 | 11 | it('supports exact length on string', () => { 12 | expectSchema([z.string().length(5).openapi('minMaxLengthString')], { 13 | minMaxLengthString: { type: 'string', minLength: 5, maxLength: 5 }, 14 | }); 15 | }); 16 | 17 | it('supports minLength / maxLength on string', () => { 18 | expectSchema([z.string().min(5).max(10).openapi('minMaxLengthString')], { 19 | minMaxLengthString: { type: 'string', minLength: 5, maxLength: 10 }, 20 | }); 21 | }); 22 | 23 | it('supports the combination of min/max + length on string', () => { 24 | expectSchema( 25 | [ 26 | z.string().length(5).min(6).openapi('minAndLengthString'), 27 | z.string().max(10).length(5).openapi('maxAndLengthString'), 28 | ], 29 | { 30 | minAndLengthString: { type: 'string', minLength: 5, maxLength: 5 }, 31 | maxAndLengthString: { type: 'string', minLength: 5, maxLength: 5 }, 32 | } 33 | ); 34 | }); 35 | 36 | it('supports string literals', () => { 37 | expectSchema([z.literal('John Doe').openapi('Literal')], { 38 | Literal: { type: 'string', enum: ['John Doe'] }, 39 | }); 40 | }); 41 | 42 | fit.each` 43 | format | zodString | expected 44 | ${'emoji'} | ${z.string().emoji()} | ${'emoji'} 45 | ${'cuid'} | ${z.string().cuid()} | ${'cuid'} 46 | ${'cuid2'} | ${z.string().cuid2()} | ${'cuid2'} 47 | ${'ulid'} | ${z.string().ulid()} | ${'ulid'} 48 | ${'ip'} | ${z.string().ip()} | ${'ip'} 49 | ${'uuid'} | ${z.string().uuid()} | ${'uuid'} 50 | ${'email'} | ${z.string().email()} | ${'email'} 51 | ${'url'} | ${z.string().url()} | ${'uri'} 52 | ${'date'} | ${z.string().date()} | ${'date'} 53 | ${'datetime'} | ${z.string().datetime()} | ${'date-time'} 54 | `( 55 | 'maps a ZodString $format to $expected format', 56 | ({ zodString, expected }: { zodString: ZodString; expected: string }) => { 57 | expectSchema([zodString.openapi('ZodString')], { 58 | ZodString: { type: 'string', format: expected }, 59 | }); 60 | } 61 | ); 62 | 63 | it('maps a ZodString regex to a pattern', () => { 64 | expectSchema( 65 | [ 66 | z 67 | .string() 68 | .regex(/^hello world/) 69 | .openapi('RegexString'), 70 | ], 71 | { 72 | RegexString: { type: 'string', pattern: '^hello world' }, 73 | } 74 | ); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /spec/types/tuple.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('tuple', () => { 5 | it('supports tuples in 3.0.0', () => { 6 | expectSchema( 7 | [z.tuple([z.string(), z.number(), z.boolean()]).openapi('Test')], 8 | { 9 | Test: { 10 | type: 'array', 11 | items: { 12 | anyOf: [ 13 | { type: 'string' }, 14 | { type: 'number' }, 15 | { type: 'boolean' }, 16 | ], 17 | }, 18 | minItems: 3, 19 | maxItems: 3, 20 | }, 21 | }, 22 | '3.0.0' 23 | ); 24 | }); 25 | 26 | it('supports tuples in 3.1.0', () => { 27 | expectSchema( 28 | [z.tuple([z.string(), z.number(), z.boolean()]).openapi('Test')], 29 | { 30 | Test: { 31 | type: 'array', 32 | prefixItems: [ 33 | { type: 'string' }, 34 | { type: 'number' }, 35 | { type: 'boolean' }, 36 | ], 37 | }, 38 | }, 39 | '3.1.0' 40 | ); 41 | }); 42 | 43 | it('supports tuples of the same single type', () => { 44 | expectSchema([z.tuple([z.string(), z.string()]).openapi('Test')], { 45 | Test: { 46 | type: 'array', 47 | items: { 48 | type: 'string', 49 | }, 50 | minItems: 2, 51 | maxItems: 2, 52 | }, 53 | }); 54 | }); 55 | 56 | it('supports tuples of duplicate types in 3.0.0', () => { 57 | expectSchema( 58 | [z.tuple([z.string(), z.number(), z.string()]).openapi('Test')], 59 | { 60 | Test: { 61 | type: 'array', 62 | items: { 63 | anyOf: [{ type: 'string' }, { type: 'number' }], 64 | }, 65 | minItems: 3, 66 | maxItems: 3, 67 | }, 68 | } 69 | ); 70 | }); 71 | 72 | it('supports tuples of duplicate types in 3.1.0', () => { 73 | expectSchema( 74 | [z.tuple([z.string(), z.number(), z.string()]).openapi('Test')], 75 | { 76 | Test: { 77 | type: 'array', 78 | prefixItems: [ 79 | { type: 'string' }, 80 | { type: 'number' }, 81 | { type: 'string' }, 82 | ], 83 | }, 84 | }, 85 | '3.1.0' 86 | ); 87 | }); 88 | 89 | it('supports tuples of referenced schemas', () => { 90 | const stringSchema = z.string().openapi('String'); 91 | 92 | const testSchema = z 93 | .tuple([stringSchema, z.number(), z.string()]) 94 | .openapi('Test'); 95 | 96 | expectSchema([stringSchema, testSchema], { 97 | String: { 98 | type: 'string', 99 | }, 100 | Test: { 101 | type: 'array', 102 | items: { 103 | anyOf: [ 104 | { $ref: '#/components/schemas/String' }, 105 | { type: 'number' }, 106 | { type: 'string' }, 107 | ], 108 | }, 109 | minItems: 3, 110 | maxItems: 3, 111 | }, 112 | }); 113 | }); 114 | 115 | it('can automatically register tuple items', () => { 116 | const schema = z 117 | .tuple([z.string().openapi('StringId'), z.number().openapi('NumberId')]) 118 | .openapi('Tuple'); 119 | 120 | expectSchema([schema], { 121 | StringId: { 122 | type: 'string', 123 | }, 124 | 125 | NumberId: { 126 | type: 'number', 127 | }, 128 | 129 | Tuple: { 130 | type: 'array', 131 | 132 | items: { 133 | anyOf: [ 134 | { $ref: '#/components/schemas/StringId' }, 135 | { $ref: '#/components/schemas/NumberId' }, 136 | ], 137 | }, 138 | maxItems: 2, 139 | minItems: 2, 140 | }, 141 | }); 142 | }); 143 | 144 | describe('nullable', () => { 145 | it('supports tuples with nullable in 3.0.0', () => { 146 | expectSchema( 147 | [z.tuple([z.string().nullable(), z.string()]).openapi('Test')], 148 | { 149 | Test: { 150 | type: 'array', 151 | items: { 152 | anyOf: [{ type: 'string', nullable: true }, { type: 'string' }], 153 | }, 154 | minItems: 2, 155 | maxItems: 2, 156 | }, 157 | }, 158 | '3.0.0' 159 | ); 160 | }); 161 | 162 | it('supports tuples with nullable in 3.1.0', () => { 163 | expectSchema( 164 | [ 165 | z 166 | .tuple([z.string().nullable(), z.number().nullable()]) 167 | .openapi('Test'), 168 | ], 169 | { 170 | Test: { 171 | type: 'array', 172 | prefixItems: [ 173 | { type: ['string', 'null'] }, 174 | { type: ['number', 'null'] }, 175 | ], 176 | }, 177 | }, 178 | '3.1.0' 179 | ); 180 | }); 181 | 182 | it('supports nullable tuples in 3.0.0', () => { 183 | expectSchema( 184 | [z.tuple([z.string(), z.number()]).nullable().openapi('Test')], 185 | { 186 | Test: { 187 | type: 'array', 188 | items: { 189 | anyOf: [{ type: 'string' }, { type: 'number' }], 190 | }, 191 | minItems: 2, 192 | maxItems: 2, 193 | nullable: true, 194 | }, 195 | }, 196 | '3.0.0' 197 | ); 198 | }); 199 | 200 | it('supports nullable tuples in 3.1.0', () => { 201 | expectSchema( 202 | [z.tuple([z.string(), z.number()]).nullable().openapi('Test')], 203 | { 204 | Test: { 205 | type: ['array', 'null'], 206 | prefixItems: [{ type: 'string' }, { type: 'number' }], 207 | }, 208 | }, 209 | '3.1.0' 210 | ); 211 | }); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /spec/types/union.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | describe('union', () => { 5 | it('supports union types', () => { 6 | expectSchema([z.string().or(z.number()).openapi('Test')], { 7 | Test: { 8 | anyOf: [{ type: 'string' }, { type: 'number' }], 9 | }, 10 | }); 11 | 12 | expectSchema( 13 | [z.string().or(z.number()).or(z.array(z.string())).openapi('Test')], 14 | { 15 | Test: { 16 | anyOf: [ 17 | { type: 'string' }, 18 | { type: 'number' }, 19 | { type: 'array', items: { type: 'string' } }, 20 | ], 21 | }, 22 | } 23 | ); 24 | }); 25 | 26 | it('can automatically register union items', () => { 27 | const schema = z 28 | .union([z.string().openapi('StringId'), z.number().openapi('NumberId')]) 29 | .openapi('Union'); 30 | 31 | expectSchema([schema], { 32 | StringId: { 33 | type: 'string', 34 | }, 35 | 36 | NumberId: { 37 | type: 'number', 38 | }, 39 | 40 | Union: { 41 | anyOf: [ 42 | { $ref: '#/components/schemas/StringId' }, 43 | { $ref: '#/components/schemas/NumberId' }, 44 | ], 45 | }, 46 | }); 47 | }); 48 | 49 | it('supports nullable union types', () => { 50 | expectSchema([z.string().or(z.number()).nullable().openapi('Test')], { 51 | Test: { 52 | anyOf: [{ type: 'string' }, { type: 'number' }, { nullable: true }], 53 | }, 54 | }); 55 | }); 56 | 57 | it('supports nullable union types in 3.1.0', () => { 58 | expectSchema( 59 | [z.string().or(z.number()).nullable().openapi('Test')], 60 | { 61 | Test: { 62 | anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }], 63 | }, 64 | }, 65 | '3.1.0' 66 | ); 67 | }); 68 | 69 | it('supports inner nullable union types', () => { 70 | // adding to .nullable() for the recursive check 71 | const test = z 72 | .union([z.string(), z.number().nullable().nullable()]) 73 | .openapi('Test'); 74 | 75 | expectSchema([test], { 76 | Test: { 77 | anyOf: [{ type: 'string' }, { type: 'number' }, { nullable: true }], 78 | }, 79 | }); 80 | }); 81 | 82 | it('supports inner nullable union types in 3.1.-', () => { 83 | // adding to .nullable() for the recursive check 84 | const test = z 85 | .union([z.string(), z.number().nullable().nullable()]) 86 | .openapi('Test'); 87 | 88 | expectSchema( 89 | [test], 90 | { 91 | Test: { 92 | anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }], 93 | }, 94 | }, 95 | '3.1.0' 96 | ); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /spec/types/unknown.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { expectSchema } from '../lib/helpers'; 3 | 4 | // Based on the "Any Type" section of https://swagger.io/docs/specification/data-models/data-types/ 5 | 6 | describe('unknown', () => { 7 | it('supports unknown for 3.0.0 ', () => { 8 | expectSchema( 9 | [z.unknown().openapi('Unknown', { description: 'Something unknown' })], 10 | { 11 | Unknown: { description: 'Something unknown', nullable: true }, 12 | } 13 | ); 14 | }); 15 | 16 | it('supports unknown for 3.1.0', () => { 17 | expectSchema( 18 | [z.unknown().openapi('Unknown', { description: 'Something unknown' })], 19 | { 20 | Unknown: { description: 'Something unknown' }, 21 | }, 22 | '3.1.0' 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class ZodToOpenAPIError { 2 | constructor(private message: string) {} 3 | } 4 | 5 | interface ConflictErrorProps { 6 | key: string; 7 | values: any[]; 8 | } 9 | 10 | export class ConflictError extends ZodToOpenAPIError { 11 | constructor(message: string, private data: ConflictErrorProps) { 12 | super(message); 13 | } 14 | } 15 | export interface MissingParameterDataErrorProps { 16 | paramName?: string; 17 | route?: string; 18 | location?: string; 19 | missingField: string; 20 | } 21 | 22 | export class MissingParameterDataError extends ZodToOpenAPIError { 23 | constructor(public data: MissingParameterDataErrorProps) { 24 | super( 25 | `Missing parameter data, please specify \`${data.missingField}\` and other OpenAPI parameter props using the \`param\` field of \`ZodSchema.openapi\`` 26 | ); 27 | } 28 | } 29 | 30 | export function enhanceMissingParametersError( 31 | action: () => T, 32 | paramsToAdd: Partial 33 | ) { 34 | try { 35 | return action(); 36 | } catch (error) { 37 | if (error instanceof MissingParameterDataError) { 38 | throw new MissingParameterDataError({ 39 | ...error.data, 40 | ...paramsToAdd, 41 | }); 42 | } 43 | throw error; 44 | } 45 | } 46 | 47 | interface UnknownZodTypeErrorProps { 48 | schemaName?: string; 49 | currentSchema: any; 50 | } 51 | 52 | export class UnknownZodTypeError extends ZodToOpenAPIError { 53 | constructor(private data: UnknownZodTypeErrorProps) { 54 | super( 55 | `Unknown zod object type, please specify \`type\` and other OpenAPI props using \`ZodSchema.openapi\`.` 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { ZodOpenAPIMetadata, extendZodWithOpenApi } from './zod-extensions'; 2 | export * from './openapi-metadata'; 3 | export { 4 | OpenAPIRegistry, 5 | RouteConfig, 6 | ResponseConfig, 7 | ZodMediaTypeObject, 8 | ZodContentObject, 9 | ZodRequestBody, 10 | } from './openapi-registry'; 11 | 12 | export { OpenApiGeneratorV3 } from './v3.0/openapi-generator'; 13 | export { OpenApiGeneratorV31 } from './v3.1/openapi-generator'; 14 | -------------------------------------------------------------------------------- /src/lib/enum-info.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Numeric enums have a reverse mapping https://www.typescriptlang.org/docs/handbook/enums.html#reverse-mappings 3 | * whereas string ones don't. 4 | * 5 | * This function checks if an enum is fully numeric - i.e all values are numbers or not. 6 | * And filters out only the actual enum values when a reverse mapping is apparent. 7 | */ 8 | export function enumInfo(enumObject: Record) { 9 | const keysExceptReverseMappings = Object.keys(enumObject).filter( 10 | key => typeof enumObject[enumObject[key] as any] !== 'number' 11 | ); 12 | 13 | const values = keysExceptReverseMappings.map(key => enumObject[key]); 14 | const numericCount = values.filter(_ => typeof _ === 'number').length; 15 | 16 | const type = 17 | numericCount === 0 18 | ? ('string' as const) 19 | : numericCount === values.length 20 | ? ('numeric' as const) 21 | : ('mixed' as const); 22 | 23 | return { values, type }; 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/lodash.ts: -------------------------------------------------------------------------------- 1 | import { isEqual, ObjectSet } from './object-set'; 2 | 3 | export function isUndefined(value: any): value is undefined { 4 | return value === undefined; 5 | } 6 | 7 | export function isNil(value: any): value is null | undefined { 8 | return value === null || value === undefined; 9 | } 10 | 11 | export function mapValues< 12 | T extends object, 13 | MapperResult, 14 | Result = { [K in keyof T]: MapperResult } 15 | >(object: T, mapper: (val: T[keyof T]) => MapperResult): Result { 16 | const result: any = {}; 17 | 18 | Object.entries(object).forEach(([key, value]) => { 19 | result[key] = mapper(value); 20 | }); 21 | 22 | return result; 23 | } 24 | 25 | export function omit< 26 | T extends object, 27 | Keys extends keyof T, 28 | Result = Omit 29 | >(object: T, keys: Keys[]): Result { 30 | const result: any = {}; 31 | 32 | Object.entries(object).forEach(([key, value]) => { 33 | if (!keys.some(keyToOmit => keyToOmit === key)) { 34 | result[key] = value; 35 | } 36 | }); 37 | 38 | return result; 39 | } 40 | 41 | export function omitBy< 42 | T extends object, 43 | Result = Partial<{ [K in keyof T]: T[keyof T] }> 44 | >(object: T, predicate: (val: T[keyof T], key: keyof T) => boolean): Result { 45 | const result: any = {}; 46 | 47 | Object.entries(object).forEach(([key, value]) => { 48 | if (!predicate(value, key as keyof T)) { 49 | result[key] = value; 50 | } 51 | }); 52 | 53 | return result; 54 | } 55 | 56 | export function compact(arr: (T | null | undefined)[]) { 57 | return arr.filter((elem): elem is T => !isNil(elem)); 58 | } 59 | 60 | export const objectEquals = isEqual; 61 | 62 | export function uniq(values: T[]) { 63 | const set = new ObjectSet(); 64 | 65 | values.forEach(value => set.put(value)); 66 | 67 | return [...set.values()]; 68 | } 69 | 70 | export function isString(val: unknown): val is string { 71 | return typeof val === 'string'; 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/object-set.ts: -------------------------------------------------------------------------------- 1 | export function isEqual(x: any, y: any): boolean { 2 | if (x === null || x === undefined || y === null || y === undefined) { 3 | return x === y; 4 | } 5 | 6 | if (x === y || x.valueOf() === y.valueOf()) { 7 | return true; 8 | } 9 | 10 | if (Array.isArray(x)) { 11 | if (!Array.isArray(y)) { 12 | return false; 13 | } 14 | 15 | if (x.length !== y.length) { 16 | return false; 17 | } 18 | } 19 | 20 | // if they are strictly equal, they both need to be object at least 21 | if (!(x instanceof Object) || !(y instanceof Object)) { 22 | return false; 23 | } 24 | 25 | // recursive object equality check 26 | const keysX = Object.keys(x); 27 | return ( 28 | Object.keys(y).every(keyY => keysX.indexOf(keyY) !== -1) && 29 | keysX.every(key => isEqual(x[key], y[key])) 30 | ); 31 | } 32 | 33 | export class ObjectSet { 34 | private buckets = new Map(); 35 | put(value: V) { 36 | const hashCode = this.hashCodeOf(value); 37 | 38 | const itemsByCode = this.buckets.get(hashCode); 39 | if (!itemsByCode) { 40 | this.buckets.set(hashCode, [value]); 41 | return; 42 | } 43 | 44 | const alreadyHasItem = itemsByCode.some(_ => isEqual(_, value)); 45 | if (!alreadyHasItem) { 46 | itemsByCode.push(value); 47 | } 48 | } 49 | 50 | contains(value: V): boolean { 51 | const hashCode = this.hashCodeOf(value); 52 | 53 | const itemsByCode = this.buckets.get(hashCode); 54 | if (!itemsByCode) { 55 | return false; 56 | } 57 | return itemsByCode.some(_ => isEqual(_, value)); 58 | } 59 | 60 | values() { 61 | return [...this.buckets.values()].flat(); 62 | } 63 | 64 | stats() { 65 | let totalBuckets = 0; 66 | let totalValues = 0; 67 | let collisions = 0; 68 | 69 | for (const bucket of this.buckets.values()) { 70 | totalBuckets += 1; 71 | totalValues += bucket.length; 72 | if (bucket.length > 1) { 73 | collisions += 1; 74 | } 75 | } 76 | 77 | const hashEffectiveness = totalBuckets / totalValues; 78 | return { totalBuckets, collisions, totalValues, hashEffectiveness }; 79 | } 80 | private hashCodeOf(object: any): number { 81 | let hashCode = 0; 82 | 83 | if (Array.isArray(object)) { 84 | for (let i = 0; i < object.length; i++) { 85 | hashCode ^= this.hashCodeOf(object[i]) * i; 86 | } 87 | return hashCode; 88 | } 89 | 90 | if (typeof object === 'string') { 91 | for (let i = 0; i < object.length; i++) { 92 | hashCode ^= object.charCodeAt(i) * i; 93 | } 94 | return hashCode; 95 | } 96 | 97 | if (typeof object === 'number') { 98 | return object; 99 | } 100 | 101 | if (typeof object === 'object') { 102 | for (const [key, value] of Object.entries(object)) { 103 | hashCode ^= this.hashCodeOf(key) + this.hashCodeOf(value ?? ''); 104 | } 105 | } 106 | 107 | return hashCode; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/lib/zod-is-type.ts: -------------------------------------------------------------------------------- 1 | import type { z } from 'zod'; 2 | 3 | export type ZodTypes = { 4 | ZodAny: z.ZodAny; 5 | ZodArray: z.ZodArray; 6 | ZodBigInt: z.ZodBigInt; 7 | ZodBoolean: z.ZodBoolean; 8 | ZodBranded: z.ZodBranded; 9 | ZodDefault: z.ZodDefault; 10 | ZodEffects: z.ZodEffects; 11 | ZodEnum: z.ZodEnum; 12 | ZodIntersection: z.ZodIntersection; 13 | ZodLiteral: z.ZodLiteral; 14 | ZodNativeEnum: z.ZodNativeEnum; 15 | ZodNever: z.ZodNever; 16 | ZodNull: z.ZodNull; 17 | ZodNullable: z.ZodNullable; 18 | ZodNumber: z.ZodNumber; 19 | ZodObject: z.AnyZodObject; 20 | ZodOptional: z.ZodOptional; 21 | ZodPipeline: z.ZodPipeline; 22 | ZodReadonly: z.ZodReadonly; 23 | ZodRecord: z.ZodRecord; 24 | ZodSchema: z.ZodSchema; 25 | ZodString: z.ZodString; 26 | ZodTuple: z.ZodTuple; 27 | ZodType: z.ZodType; 28 | ZodTypeAny: z.ZodTypeAny; 29 | ZodUnion: z.ZodUnion; 30 | ZodDiscriminatedUnion: z.ZodDiscriminatedUnion; 31 | ZodUnknown: z.ZodUnknown; 32 | ZodVoid: z.ZodVoid; 33 | ZodDate: z.ZodDate; 34 | }; 35 | 36 | export function isZodType( 37 | schema: object, 38 | typeName: TypeName 39 | ): schema is ZodTypes[TypeName] { 40 | return (schema as any)?._def?.typeName === typeName; 41 | } 42 | 43 | export function isAnyZodType(schema: object): schema is z.ZodType { 44 | return '_def' in schema; 45 | } 46 | -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | import { ZodType, ZodTypeAny } from 'zod'; 2 | import { ZodTypes, isZodType } from './lib/zod-is-type'; 3 | import { ZodOpenAPIMetadata, ZodOpenApiFullMetadata } from './zod-extensions'; 4 | import { isNil, omit, omitBy } from './lib/lodash'; 5 | import { ParameterObject, ReferenceObject, SchemaObject } from './types'; 6 | 7 | export class Metadata { 8 | static getMetadata( 9 | zodSchema: ZodType 10 | ): ZodOpenApiFullMetadata | undefined { 11 | const innerSchema = this.unwrapChained(zodSchema); 12 | 13 | const metadata = zodSchema._def.openapi 14 | ? zodSchema._def.openapi 15 | : innerSchema._def.openapi; 16 | 17 | /** 18 | * Every zod schema can receive a `description` by using the .describe method. 19 | * That description should be used when generating an OpenApi schema. 20 | * The `??` bellow makes sure we can handle both: 21 | * - schema.describe('Test').optional() 22 | * - schema.optional().describe('Test') 23 | */ 24 | const zodDescription = zodSchema.description ?? innerSchema.description; 25 | 26 | // A description provided from .openapi() should be taken with higher precedence 27 | return { 28 | _internal: metadata?._internal, 29 | metadata: { 30 | description: zodDescription, 31 | ...metadata?.metadata, 32 | }, 33 | }; 34 | } 35 | 36 | static getInternalMetadata(zodSchema: ZodType) { 37 | const innerSchema = this.unwrapChained(zodSchema); 38 | const openapi = zodSchema._def.openapi 39 | ? zodSchema._def.openapi 40 | : innerSchema._def.openapi; 41 | 42 | return openapi?._internal; 43 | } 44 | 45 | static getParamMetadata( 46 | zodSchema: ZodType 47 | ): ZodOpenApiFullMetadata | undefined { 48 | const innerSchema = this.unwrapChained(zodSchema); 49 | 50 | const metadata = zodSchema._def.openapi 51 | ? zodSchema._def.openapi 52 | : innerSchema._def.openapi; 53 | 54 | /** 55 | * Every zod schema can receive a `description` by using the .describe method. 56 | * That description should be used when generating an OpenApi schema. 57 | * The `??` bellow makes sure we can handle both: 58 | * - schema.describe('Test').optional() 59 | * - schema.optional().describe('Test') 60 | */ 61 | const zodDescription = zodSchema.description ?? innerSchema.description; 62 | 63 | return { 64 | _internal: metadata?._internal, 65 | metadata: { 66 | ...metadata?.metadata, 67 | // A description provided from .openapi() should be taken with higher precedence 68 | param: { 69 | description: zodDescription, 70 | ...metadata?.metadata?.param, 71 | }, 72 | }, 73 | }; 74 | } 75 | 76 | /** 77 | * A method that omits all custom keys added to the regular OpenAPI 78 | * metadata properties 79 | */ 80 | static buildSchemaMetadata(metadata: ZodOpenAPIMetadata) { 81 | return omitBy(omit(metadata, ['param']), isNil); 82 | } 83 | 84 | static buildParameterMetadata( 85 | metadata: Required['param'] 86 | ) { 87 | return omitBy(metadata, isNil); 88 | } 89 | 90 | static applySchemaMetadata( 91 | initialData: SchemaObject | ParameterObject | ReferenceObject, 92 | metadata: Partial 93 | ): SchemaObject | ReferenceObject { 94 | return omitBy( 95 | { 96 | ...initialData, 97 | ...this.buildSchemaMetadata(metadata), 98 | }, 99 | isNil 100 | ); 101 | } 102 | 103 | static getRefId(zodSchema: ZodType) { 104 | return this.getInternalMetadata(zodSchema)?.refId; 105 | } 106 | 107 | static unwrapChained(schema: ZodType): ZodType { 108 | return this.unwrapUntil(schema); 109 | } 110 | 111 | static getDefaultValue(zodSchema: ZodTypeAny): T | undefined { 112 | const unwrapped = this.unwrapUntil(zodSchema, 'ZodDefault'); 113 | 114 | return unwrapped?._def.defaultValue(); 115 | } 116 | 117 | private static unwrapUntil(schema: ZodType): ZodType; 118 | private static unwrapUntil( 119 | schema: ZodType, 120 | typeName: TypeName | undefined 121 | ): ZodTypes[TypeName] | undefined; 122 | private static unwrapUntil( 123 | schema: ZodType, 124 | typeName?: TypeName 125 | ): ZodType | undefined { 126 | if (typeName && isZodType(schema, typeName)) { 127 | return schema; 128 | } 129 | 130 | if ( 131 | isZodType(schema, 'ZodOptional') || 132 | isZodType(schema, 'ZodNullable') || 133 | isZodType(schema, 'ZodBranded') 134 | ) { 135 | return this.unwrapUntil(schema.unwrap(), typeName); 136 | } 137 | 138 | if (isZodType(schema, 'ZodDefault') || isZodType(schema, 'ZodReadonly')) { 139 | return this.unwrapUntil(schema._def.innerType, typeName); 140 | } 141 | 142 | if (isZodType(schema, 'ZodEffects')) { 143 | return this.unwrapUntil(schema._def.schema, typeName); 144 | } 145 | 146 | if (isZodType(schema, 'ZodPipeline')) { 147 | return this.unwrapUntil(schema._def.in, typeName); 148 | } 149 | 150 | return typeName ? undefined : schema; 151 | } 152 | 153 | static isOptionalSchema(zodSchema: ZodTypeAny): boolean { 154 | return zodSchema.isOptional(); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/openapi-metadata.ts: -------------------------------------------------------------------------------- 1 | import { ZodTypeAny } from 'zod'; 2 | import { isNil, omitBy } from './lib/lodash'; 3 | 4 | export function getOpenApiMetadata(zodSchema: T) { 5 | return omitBy(zodSchema._def.openapi?.metadata ?? {}, isNil); 6 | } 7 | -------------------------------------------------------------------------------- /src/openapi-registry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallbackObject as CallbackObject30, 3 | ComponentsObject as ComponentsObject30, 4 | EncodingObject as EncodingObject30, 5 | ExampleObject as ExampleObject30, 6 | ExamplesObject as ExamplesObject30, 7 | HeaderObject as HeaderObject30, 8 | HeadersObject as HeadersObject30, 9 | ISpecificationExtension as ISpecificationExtension30, 10 | LinkObject as LinkObject30, 11 | LinksObject as LinksObject30, 12 | OperationObject as OperationObject30, 13 | ParameterObject as ParameterObject30, 14 | ReferenceObject as ReferenceObject30, 15 | RequestBodyObject as RequestBodyObject30, 16 | ResponseObject as ResponseObject30, 17 | SchemaObject as SchemaObject30, 18 | SecuritySchemeObject as SecuritySchemeObject30, 19 | } from 'openapi3-ts/oas30'; 20 | 21 | import { 22 | CallbackObject as CallbackObject31, 23 | ComponentsObject as ComponentsObject31, 24 | EncodingObject as EncodingObject31, 25 | ExampleObject as ExampleObject31, 26 | ExamplesObject as ExamplesObject31, 27 | HeaderObject as HeaderObject31, 28 | HeadersObject as HeadersObject31, 29 | ISpecificationExtension as ISpecificationExtension31, 30 | LinkObject as LinkObject31, 31 | LinksObject as LinksObject31, 32 | OperationObject as OperationObject31, 33 | ParameterObject as ParameterObject31, 34 | ReferenceObject as ReferenceObject31, 35 | RequestBodyObject as RequestBodyObject31, 36 | ResponseObject as ResponseObject31, 37 | SchemaObject as SchemaObject31, 38 | SecuritySchemeObject as SecuritySchemeObject31, 39 | } from 'openapi3-ts/oas31'; 40 | 41 | type CallbackObject = CallbackObject30 | CallbackObject31; 42 | type ComponentsObject = ComponentsObject30 | ComponentsObject31; 43 | type EncodingObject = EncodingObject30 | EncodingObject31; 44 | type ExampleObject = ExampleObject30 | ExampleObject31; 45 | type ExamplesObject = ExamplesObject30 | ExamplesObject31; 46 | type HeaderObject = HeaderObject30 | HeaderObject31; 47 | type HeadersObject = HeadersObject30 | HeadersObject31; 48 | type ISpecificationExtension = 49 | | ISpecificationExtension30 50 | | ISpecificationExtension31; 51 | type LinkObject = LinkObject30 | LinkObject31; 52 | type LinksObject = LinksObject30 | LinksObject31; 53 | type OperationObject = OperationObject30 | OperationObject31; 54 | type ParameterObject = ParameterObject30 | ParameterObject31; 55 | type ReferenceObject = ReferenceObject30 | ReferenceObject31; 56 | type RequestBodyObject = RequestBodyObject30 | RequestBodyObject31; 57 | type ResponseObject = ResponseObject30 | ResponseObject31; 58 | type SchemaObject = SchemaObject30 | SchemaObject31; 59 | type SecuritySchemeObject = SecuritySchemeObject30 | SecuritySchemeObject31; 60 | 61 | import type { AnyZodObject, ZodEffects, ZodType, ZodTypeAny } from 'zod'; 62 | 63 | type Method = 64 | | 'get' 65 | | 'post' 66 | | 'put' 67 | | 'delete' 68 | | 'patch' 69 | | 'head' 70 | | 'options' 71 | | 'trace'; 72 | 73 | export interface ZodMediaTypeObject { 74 | schema: ZodType | SchemaObject | ReferenceObject; 75 | examples?: ExamplesObject; 76 | example?: any; 77 | encoding?: EncodingObject; 78 | } 79 | 80 | // Provide autocompletion on media type with most common one without restricting to anything. 81 | export type ZodMediaType = 82 | | 'application/json' 83 | | 'text/html' 84 | | 'text/plain' 85 | | 'application/xml' 86 | | (string & {}); 87 | 88 | export type ZodContentObject = Partial< 89 | Record 90 | >; 91 | 92 | export interface ZodRequestBody { 93 | description?: string; 94 | content: ZodContentObject; 95 | required?: boolean; 96 | } 97 | 98 | export interface ResponseConfig { 99 | description: string; 100 | headers?: AnyZodObject | HeadersObject; 101 | links?: LinksObject; 102 | content?: ZodContentObject; 103 | } 104 | 105 | type ZodObjectWithEffect = 106 | | AnyZodObject 107 | | ZodEffects; 108 | 109 | export type RouteParameter = ZodObjectWithEffect | undefined; 110 | 111 | export type RouteConfig = Omit & { 112 | method: Method; 113 | path: string; 114 | request?: { 115 | body?: ZodRequestBody; 116 | params?: RouteParameter; 117 | query?: RouteParameter; 118 | cookies?: RouteParameter; 119 | headers?: RouteParameter | ZodType[]; 120 | }; 121 | responses: { 122 | [statusCode: string]: ResponseConfig | ReferenceObject; 123 | }; 124 | }; 125 | 126 | export type OpenAPIComponentObject = 127 | | SchemaObject 128 | | ResponseObject 129 | | ParameterObject 130 | | ExampleObject 131 | | RequestBodyObject 132 | | HeaderObject 133 | | SecuritySchemeObject 134 | | LinkObject 135 | | CallbackObject 136 | | ISpecificationExtension; 137 | 138 | export type ComponentTypeKey = Exclude; 139 | export type ComponentTypeOf = NonNullable< 140 | ComponentsObject[K] 141 | >[string]; 142 | 143 | export type WebhookDefinition = { type: 'webhook'; webhook: RouteConfig }; 144 | 145 | export type OpenAPIDefinitions = 146 | | { 147 | type: 'component'; 148 | componentType: ComponentTypeKey; 149 | name: string; 150 | component: OpenAPIComponentObject; 151 | } 152 | | { type: 'schema'; schema: ZodTypeAny } 153 | | { type: 'parameter'; schema: ZodTypeAny } 154 | | { type: 'route'; route: RouteConfig } 155 | | WebhookDefinition; 156 | 157 | export class OpenAPIRegistry { 158 | private _definitions: OpenAPIDefinitions[] = []; 159 | 160 | constructor(private parents?: OpenAPIRegistry[]) {} 161 | 162 | get definitions(): OpenAPIDefinitions[] { 163 | const parentDefinitions = 164 | this.parents?.flatMap(par => par.definitions) ?? []; 165 | 166 | return [...parentDefinitions, ...this._definitions]; 167 | } 168 | 169 | /** 170 | * Registers a new component schema under /components/schemas/${name} 171 | */ 172 | register(refId: string, zodSchema: T): T { 173 | const schemaWithRefId = this.schemaWithRefId(refId, zodSchema); 174 | 175 | this._definitions.push({ type: 'schema', schema: schemaWithRefId }); 176 | 177 | return schemaWithRefId; 178 | } 179 | 180 | /** 181 | * Registers a new parameter schema under /components/parameters/${name} 182 | */ 183 | registerParameter(refId: string, zodSchema: T) { 184 | const schemaWithRefId = this.schemaWithRefId(refId, zodSchema); 185 | 186 | const currentMetadata = schemaWithRefId._def.openapi?.metadata; 187 | 188 | const schemaWithMetadata = schemaWithRefId.openapi({ 189 | ...currentMetadata, 190 | param: { 191 | ...currentMetadata?.param, 192 | name: currentMetadata?.param?.name ?? refId, 193 | }, 194 | }); 195 | 196 | this._definitions.push({ 197 | type: 'parameter', 198 | schema: schemaWithMetadata, 199 | }); 200 | 201 | return schemaWithMetadata; 202 | } 203 | 204 | /** 205 | * Registers a new path that would be generated under paths: 206 | */ 207 | registerPath(route: RouteConfig) { 208 | this._definitions.push({ 209 | type: 'route', 210 | route, 211 | }); 212 | } 213 | 214 | /** 215 | * Registers a new webhook that would be generated under webhooks: 216 | */ 217 | registerWebhook(webhook: RouteConfig) { 218 | this._definitions.push({ 219 | type: 'webhook', 220 | webhook, 221 | }); 222 | } 223 | 224 | /** 225 | * Registers a raw OpenAPI component. Use this if you have a simple object instead of a Zod schema. 226 | * 227 | * @param type The component type, e.g. `schemas`, `responses`, `securitySchemes`, etc. 228 | * @param name The name of the object, it is the key under the component 229 | * type in the resulting OpenAPI document 230 | * @param component The actual object to put there 231 | */ 232 | registerComponent( 233 | type: K, 234 | name: string, 235 | component: ComponentTypeOf 236 | ) { 237 | this._definitions.push({ 238 | type: 'component', 239 | componentType: type, 240 | name, 241 | component, 242 | }); 243 | 244 | return { 245 | name, 246 | ref: { $ref: `#/components/${type}/${name}` }, 247 | }; 248 | } 249 | 250 | private schemaWithRefId( 251 | refId: string, 252 | zodSchema: T 253 | ): T { 254 | return zodSchema.openapi(refId); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/transformers/array.ts: -------------------------------------------------------------------------------- 1 | import { ZodTypeAny, ZodArray } from 'zod'; 2 | import { MapNullableType, MapSubSchema } from '../types'; 3 | 4 | export class ArrayTransformer { 5 | transform( 6 | zodSchema: ZodArray, 7 | mapNullableType: MapNullableType, 8 | mapItems: MapSubSchema 9 | ) { 10 | const itemType = zodSchema._def.type; 11 | 12 | return { 13 | ...mapNullableType('array'), 14 | items: mapItems(itemType), 15 | 16 | minItems: zodSchema._def.minLength?.value, 17 | maxItems: zodSchema._def.maxLength?.value, 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/transformers/big-int.ts: -------------------------------------------------------------------------------- 1 | import { MapNullableType } from '../types'; 2 | 3 | export class BigIntTransformer { 4 | transform(mapNullableType: MapNullableType) { 5 | return { 6 | ...mapNullableType('string'), 7 | pattern: `^\d+$`, 8 | }; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/transformers/discriminated-union.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ZodDiscriminatedUnion, 3 | ZodDiscriminatedUnionOption, 4 | AnyZodObject, 5 | } from 'zod'; 6 | import { 7 | DiscriminatorObject, 8 | MapNullableOfArrayWithNullable, 9 | MapSubSchema, 10 | } from '../types'; 11 | import { isString } from '../lib/lodash'; 12 | import { isZodType } from '../lib/zod-is-type'; 13 | import { Metadata } from '../metadata'; 14 | 15 | export class DiscriminatedUnionTransformer { 16 | transform( 17 | zodSchema: ZodDiscriminatedUnion< 18 | string, 19 | ZodDiscriminatedUnionOption[] 20 | >, 21 | isNullable: boolean, 22 | mapNullableOfArray: MapNullableOfArrayWithNullable, 23 | mapItem: MapSubSchema, 24 | generateSchemaRef: (schema: string) => string 25 | ) { 26 | const options = [...zodSchema.options.values()]; 27 | 28 | const optionSchema = options.map(mapItem); 29 | 30 | if (isNullable) { 31 | return { 32 | oneOf: mapNullableOfArray(optionSchema, isNullable), 33 | }; 34 | } 35 | 36 | return { 37 | oneOf: optionSchema, 38 | discriminator: this.mapDiscriminator( 39 | options, 40 | zodSchema.discriminator, 41 | generateSchemaRef 42 | ), 43 | }; 44 | } 45 | 46 | private mapDiscriminator( 47 | zodObjects: AnyZodObject[], 48 | discriminator: string, 49 | generateSchemaRef: (schema: string) => string 50 | ): DiscriminatorObject | undefined { 51 | // All schemas must be registered to use a discriminator 52 | if (zodObjects.some(obj => Metadata.getRefId(obj) === undefined)) { 53 | return undefined; 54 | } 55 | 56 | const mapping: Record = {}; 57 | zodObjects.forEach(obj => { 58 | const refId = Metadata.getRefId(obj) as string; // type-checked earlier 59 | const value = obj.shape?.[discriminator]; 60 | 61 | if (isZodType(value, 'ZodEnum') || isZodType(value, 'ZodNativeEnum')) { 62 | // Native enums have their keys as both number and strings however the number is an 63 | // internal representation and the string is the access point for a documentation 64 | const keys = Object.values(value.enum).filter(isString); 65 | 66 | keys.forEach((enumValue: string) => { 67 | mapping[enumValue] = generateSchemaRef(refId); 68 | }); 69 | return; 70 | } 71 | 72 | const literalValue = value?._def.value; 73 | 74 | // This should never happen because Zod checks the disciminator type but to keep the types happy 75 | if (typeof literalValue !== 'string') { 76 | throw new Error( 77 | `Discriminator ${discriminator} could not be found in one of the values of a discriminated union` 78 | ); 79 | } 80 | 81 | mapping[literalValue] = generateSchemaRef(refId); 82 | }); 83 | 84 | return { 85 | propertyName: discriminator, 86 | mapping, 87 | }; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/transformers/enum.ts: -------------------------------------------------------------------------------- 1 | import { ZodEnum } from 'zod'; 2 | import { MapNullableType } from '../types'; 3 | 4 | export class EnumTransformer { 5 | transform( 6 | zodSchema: ZodEnum, 7 | mapNullableType: MapNullableType 8 | ) { 9 | // ZodEnum only accepts strings 10 | return { 11 | ...mapNullableType('string'), 12 | enum: zodSchema._def.values, 13 | }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/transformers/index.ts: -------------------------------------------------------------------------------- 1 | import { SchemaObject, ReferenceObject, MapSubSchema } from '../types'; 2 | import { ZodType } from 'zod'; 3 | import { UnknownZodTypeError } from '../errors'; 4 | import { isZodType } from '../lib/zod-is-type'; 5 | import { Metadata } from '../metadata'; 6 | import { ArrayTransformer } from './array'; 7 | import { BigIntTransformer } from './big-int'; 8 | import { DiscriminatedUnionTransformer } from './discriminated-union'; 9 | import { EnumTransformer } from './enum'; 10 | import { IntersectionTransformer } from './intersection'; 11 | import { LiteralTransformer } from './literal'; 12 | import { NativeEnumTransformer } from './native-enum'; 13 | import { NumberTransformer } from './number'; 14 | import { ObjectTransformer } from './object'; 15 | import { RecordTransformer } from './record'; 16 | import { StringTransformer } from './string'; 17 | import { TupleTransformer } from './tuple'; 18 | import { UnionTransformer } from './union'; 19 | import { OpenApiVersionSpecifics } from '../openapi-generator'; 20 | 21 | export class OpenApiTransformer { 22 | private objectTransformer = new ObjectTransformer(); 23 | private stringTransformer = new StringTransformer(); 24 | private numberTransformer = new NumberTransformer(); 25 | private bigIntTransformer = new BigIntTransformer(); 26 | private literalTransformer = new LiteralTransformer(); 27 | private enumTransformer = new EnumTransformer(); 28 | private nativeEnumTransformer = new NativeEnumTransformer(); 29 | private arrayTransformer = new ArrayTransformer(); 30 | private tupleTransformer: TupleTransformer; 31 | private unionTransformer = new UnionTransformer(); 32 | private discriminatedUnionTransformer = new DiscriminatedUnionTransformer(); 33 | private intersectionTransformer = new IntersectionTransformer(); 34 | private recordTransformer = new RecordTransformer(); 35 | 36 | constructor(private versionSpecifics: OpenApiVersionSpecifics) { 37 | this.tupleTransformer = new TupleTransformer(versionSpecifics); 38 | } 39 | 40 | transform( 41 | zodSchema: ZodType, 42 | isNullable: boolean, 43 | mapItem: MapSubSchema, 44 | generateSchemaRef: (ref: string) => string, 45 | defaultValue?: T 46 | ): SchemaObject | ReferenceObject { 47 | if (isZodType(zodSchema, 'ZodNull')) { 48 | return this.versionSpecifics.nullType; 49 | } 50 | 51 | if (isZodType(zodSchema, 'ZodUnknown') || isZodType(zodSchema, 'ZodAny')) { 52 | return this.versionSpecifics.mapNullableType(undefined, isNullable); 53 | } 54 | 55 | if (isZodType(zodSchema, 'ZodObject')) { 56 | return this.objectTransformer.transform( 57 | zodSchema, 58 | defaultValue as object, // verified on TS level from input 59 | _ => this.versionSpecifics.mapNullableType(_, isNullable), 60 | mapItem 61 | ); 62 | } 63 | 64 | const schema = this.transformSchemaWithoutDefault( 65 | zodSchema, 66 | isNullable, 67 | mapItem, 68 | generateSchemaRef 69 | ); 70 | 71 | return { ...schema, default: defaultValue }; 72 | } 73 | 74 | private transformSchemaWithoutDefault( 75 | zodSchema: ZodType, 76 | isNullable: boolean, 77 | mapItem: MapSubSchema, 78 | generateSchemaRef: (ref: string) => string 79 | ): SchemaObject | ReferenceObject { 80 | if (isZodType(zodSchema, 'ZodUnknown') || isZodType(zodSchema, 'ZodAny')) { 81 | return this.versionSpecifics.mapNullableType(undefined, isNullable); 82 | } 83 | 84 | if (isZodType(zodSchema, 'ZodString')) { 85 | return this.stringTransformer.transform(zodSchema, schema => 86 | this.versionSpecifics.mapNullableType(schema, isNullable) 87 | ); 88 | } 89 | 90 | if (isZodType(zodSchema, 'ZodNumber')) { 91 | return this.numberTransformer.transform( 92 | zodSchema, 93 | schema => this.versionSpecifics.mapNullableType(schema, isNullable), 94 | _ => this.versionSpecifics.getNumberChecks(_) 95 | ); 96 | } 97 | 98 | if (isZodType(zodSchema, 'ZodBigInt')) { 99 | return this.bigIntTransformer.transform(schema => 100 | this.versionSpecifics.mapNullableType(schema, isNullable) 101 | ); 102 | } 103 | 104 | if (isZodType(zodSchema, 'ZodBoolean')) { 105 | return this.versionSpecifics.mapNullableType('boolean', isNullable); 106 | } 107 | 108 | if (isZodType(zodSchema, 'ZodLiteral')) { 109 | return this.literalTransformer.transform(zodSchema, schema => 110 | this.versionSpecifics.mapNullableType(schema, isNullable) 111 | ); 112 | } 113 | 114 | if (isZodType(zodSchema, 'ZodEnum')) { 115 | return this.enumTransformer.transform(zodSchema, schema => 116 | this.versionSpecifics.mapNullableType(schema, isNullable) 117 | ); 118 | } 119 | 120 | if (isZodType(zodSchema, 'ZodNativeEnum')) { 121 | return this.nativeEnumTransformer.transform(zodSchema, schema => 122 | this.versionSpecifics.mapNullableType(schema, isNullable) 123 | ); 124 | } 125 | 126 | if (isZodType(zodSchema, 'ZodArray')) { 127 | return this.arrayTransformer.transform( 128 | zodSchema, 129 | _ => this.versionSpecifics.mapNullableType(_, isNullable), 130 | mapItem 131 | ); 132 | } 133 | 134 | if (isZodType(zodSchema, 'ZodTuple')) { 135 | return this.tupleTransformer.transform( 136 | zodSchema, 137 | _ => this.versionSpecifics.mapNullableType(_, isNullable), 138 | mapItem 139 | ); 140 | } 141 | 142 | if (isZodType(zodSchema, 'ZodUnion')) { 143 | return this.unionTransformer.transform( 144 | zodSchema, 145 | _ => this.versionSpecifics.mapNullableOfArray(_, isNullable), 146 | mapItem 147 | ); 148 | } 149 | 150 | if (isZodType(zodSchema, 'ZodDiscriminatedUnion')) { 151 | return this.discriminatedUnionTransformer.transform( 152 | zodSchema, 153 | isNullable, 154 | _ => this.versionSpecifics.mapNullableOfArray(_, isNullable), 155 | mapItem, 156 | generateSchemaRef 157 | ); 158 | } 159 | 160 | if (isZodType(zodSchema, 'ZodIntersection')) { 161 | return this.intersectionTransformer.transform( 162 | zodSchema, 163 | isNullable, 164 | _ => this.versionSpecifics.mapNullableOfArray(_, isNullable), 165 | mapItem 166 | ); 167 | } 168 | 169 | if (isZodType(zodSchema, 'ZodRecord')) { 170 | return this.recordTransformer.transform( 171 | zodSchema, 172 | _ => this.versionSpecifics.mapNullableType(_, isNullable), 173 | mapItem 174 | ); 175 | } 176 | 177 | if (isZodType(zodSchema, 'ZodDate')) { 178 | return this.versionSpecifics.mapNullableType('string', isNullable); 179 | } 180 | 181 | const refId = Metadata.getRefId(zodSchema); 182 | 183 | throw new UnknownZodTypeError({ 184 | currentSchema: zodSchema._def, 185 | schemaName: refId, 186 | }); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/transformers/intersection.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MapNullableOfArrayWithNullable, 3 | MapSubSchema, 4 | SchemaObject, 5 | } from '../types'; 6 | import { ZodIntersection, ZodTypeAny } from 'zod'; 7 | import { isZodType } from '../lib/zod-is-type'; 8 | 9 | export class IntersectionTransformer { 10 | transform( 11 | zodSchema: ZodIntersection, 12 | isNullable: boolean, 13 | mapNullableOfArray: MapNullableOfArrayWithNullable, 14 | mapItem: MapSubSchema 15 | ): SchemaObject { 16 | const subtypes = this.flattenIntersectionTypes(zodSchema); 17 | 18 | const allOfSchema: SchemaObject = { 19 | allOf: subtypes.map(mapItem), 20 | }; 21 | 22 | if (isNullable) { 23 | return { 24 | anyOf: mapNullableOfArray([allOfSchema], isNullable), 25 | }; 26 | } 27 | 28 | return allOfSchema; 29 | } 30 | 31 | private flattenIntersectionTypes(schema: ZodTypeAny): ZodTypeAny[] { 32 | if (!isZodType(schema, 'ZodIntersection')) { 33 | return [schema]; 34 | } 35 | 36 | const leftSubTypes = this.flattenIntersectionTypes(schema._def.left); 37 | const rightSubTypes = this.flattenIntersectionTypes(schema._def.right); 38 | 39 | return [...leftSubTypes, ...rightSubTypes]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/transformers/literal.ts: -------------------------------------------------------------------------------- 1 | import { ZodLiteral } from 'zod'; 2 | import { MapNullableType, SchemaObject } from '../types'; 3 | 4 | export class LiteralTransformer { 5 | transform(zodSchema: ZodLiteral, mapNullableType: MapNullableType) { 6 | return { 7 | ...mapNullableType( 8 | typeof zodSchema._def.value as NonNullable 9 | ), 10 | enum: [zodSchema._def.value], 11 | }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/transformers/native-enum.ts: -------------------------------------------------------------------------------- 1 | import { EnumLike, ZodNativeEnum } from 'zod'; 2 | import { ZodToOpenAPIError } from '../errors'; 3 | import { enumInfo } from '../lib/enum-info'; 4 | import { MapNullableType } from '../types'; 5 | 6 | export class NativeEnumTransformer { 7 | transform( 8 | zodSchema: ZodNativeEnum, 9 | mapNullableType: MapNullableType 10 | ) { 11 | const { type, values } = enumInfo(zodSchema._def.values); 12 | 13 | if (type === 'mixed') { 14 | // enum Test { 15 | // A = 42, 16 | // B = 'test', 17 | // } 18 | // 19 | // const result = z.nativeEnum(Test).parse('42'); 20 | // 21 | // This is an error, so we can't just say it's a 'string' 22 | throw new ZodToOpenAPIError( 23 | 'Enum has mixed string and number values, please specify the OpenAPI type manually' 24 | ); 25 | } 26 | 27 | return { 28 | ...mapNullableType(type === 'numeric' ? 'integer' : 'string'), 29 | enum: values, 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/transformers/number.ts: -------------------------------------------------------------------------------- 1 | import { ZodNumber } from 'zod'; 2 | import { MapNullableType, GetNumberChecks } from '../types'; 3 | 4 | export class NumberTransformer { 5 | transform( 6 | zodSchema: ZodNumber, 7 | mapNullableType: MapNullableType, 8 | getNumberChecks: GetNumberChecks 9 | ) { 10 | return { 11 | ...mapNullableType(zodSchema.isInt ? 'integer' : 'number'), 12 | ...getNumberChecks(zodSchema._def.checks), 13 | }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/transformers/object.ts: -------------------------------------------------------------------------------- 1 | import { MapNullableType, MapSubSchema, SchemaObject } from '../types'; 2 | import { UnknownKeysParam, ZodObject, ZodRawShape, z } from 'zod'; 3 | import { isZodType } from '../lib/zod-is-type'; 4 | import { mapValues, objectEquals } from '../lib/lodash'; 5 | import { Metadata } from '../metadata'; 6 | 7 | export class ObjectTransformer { 8 | transform( 9 | zodSchema: ZodObject, 10 | defaultValue: object, 11 | mapNullableType: MapNullableType, 12 | mapItem: MapSubSchema 13 | ): SchemaObject { 14 | const extendedFrom = Metadata.getInternalMetadata(zodSchema)?.extendedFrom; 15 | 16 | const required = this.requiredKeysOf(zodSchema); 17 | const properties = mapValues(zodSchema._def.shape(), mapItem); 18 | 19 | if (!extendedFrom) { 20 | return { 21 | ...mapNullableType('object'), 22 | properties, 23 | 24 | default: defaultValue, 25 | 26 | ...(required.length > 0 ? { required } : {}), 27 | 28 | ...this.generateAdditionalProperties(zodSchema, mapItem), 29 | }; 30 | } 31 | 32 | const parent = extendedFrom.schema; 33 | // We want to generate the parent schema so that it can be referenced down the line 34 | mapItem(parent); 35 | 36 | const keysRequiredByParent = this.requiredKeysOf(parent); 37 | const propsOfParent = mapValues(parent?._def.shape(), mapItem); 38 | 39 | const propertiesToAdd = Object.fromEntries( 40 | Object.entries(properties).filter(([key, type]) => { 41 | return !objectEquals(propsOfParent[key], type); 42 | }) 43 | ); 44 | 45 | const additionallyRequired = required.filter( 46 | prop => !keysRequiredByParent.includes(prop) 47 | ); 48 | 49 | const objectData = { 50 | ...mapNullableType('object'), 51 | default: defaultValue, 52 | properties: propertiesToAdd, 53 | 54 | ...(additionallyRequired.length > 0 55 | ? { required: additionallyRequired } 56 | : {}), 57 | 58 | ...this.generateAdditionalProperties(zodSchema, mapItem), 59 | }; 60 | 61 | return { 62 | allOf: [ 63 | { $ref: `#/components/schemas/${extendedFrom.refId}` }, 64 | objectData, 65 | ], 66 | }; 67 | } 68 | 69 | private generateAdditionalProperties( 70 | zodSchema: ZodObject, 71 | mapItem: MapSubSchema 72 | ) { 73 | const unknownKeysOption = zodSchema._def.unknownKeys; 74 | 75 | const catchallSchema = zodSchema._def.catchall; 76 | 77 | if (isZodType(catchallSchema, 'ZodNever')) { 78 | if (unknownKeysOption === 'strict') { 79 | return { additionalProperties: false }; 80 | } 81 | 82 | return {}; 83 | } 84 | 85 | return { additionalProperties: mapItem(catchallSchema) }; 86 | } 87 | 88 | private requiredKeysOf( 89 | objectSchema: ZodObject 90 | ) { 91 | return Object.entries(objectSchema._def.shape()) 92 | .filter(([_key, type]) => !Metadata.isOptionalSchema(type)) 93 | .map(([key, _type]) => key); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/transformers/record.ts: -------------------------------------------------------------------------------- 1 | import { MapNullableType, MapSubSchema, SchemaObject } from '../types'; 2 | import { ZodRecord } from 'zod'; 3 | import { isZodType } from '../lib/zod-is-type'; 4 | import { isString } from '../lib/lodash'; 5 | 6 | export class RecordTransformer { 7 | transform( 8 | zodSchema: ZodRecord, 9 | mapNullableType: MapNullableType, 10 | mapItem: MapSubSchema 11 | ): SchemaObject { 12 | const propertiesType = zodSchema._def.valueType; 13 | const keyType = zodSchema._def.keyType; 14 | 15 | const propertiesSchema = mapItem(propertiesType); 16 | 17 | if (isZodType(keyType, 'ZodEnum') || isZodType(keyType, 'ZodNativeEnum')) { 18 | // Native enums have their keys as both number and strings however the number is an 19 | // internal representation and the string is the access point for a documentation 20 | const keys = Object.values(keyType.enum).filter(isString); 21 | 22 | const properties = keys.reduce( 23 | (acc, curr) => ({ 24 | ...acc, 25 | [curr]: propertiesSchema, 26 | }), 27 | {} as SchemaObject['properties'] 28 | ); 29 | 30 | return { 31 | ...mapNullableType('object'), 32 | properties, 33 | }; 34 | } 35 | 36 | return { 37 | ...mapNullableType('object'), 38 | additionalProperties: propertiesSchema, 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/transformers/string.ts: -------------------------------------------------------------------------------- 1 | import { ZodString, ZodStringDef } from 'zod'; 2 | import { MapNullableType } from '../types'; 3 | 4 | export class StringTransformer { 5 | transform(zodSchema: ZodString, mapNullableType: MapNullableType) { 6 | const regexCheck = this.getZodStringCheck(zodSchema, 'regex'); 7 | 8 | const length = this.getZodStringCheck(zodSchema, 'length')?.value; 9 | 10 | const maxLength = Number.isFinite(zodSchema.minLength) 11 | ? zodSchema.minLength ?? undefined 12 | : undefined; 13 | 14 | const minLength = Number.isFinite(zodSchema.maxLength) 15 | ? zodSchema.maxLength ?? undefined 16 | : undefined; 17 | 18 | return { 19 | ...mapNullableType('string'), 20 | // FIXME: https://github.com/colinhacks/zod/commit/d78047e9f44596a96d637abb0ce209cd2732d88c 21 | minLength: length ?? maxLength, 22 | maxLength: length ?? minLength, 23 | format: this.mapStringFormat(zodSchema), 24 | pattern: regexCheck?.regex.source, 25 | }; 26 | } 27 | 28 | /** 29 | * Attempts to map Zod strings to known formats 30 | * https://json-schema.org/understanding-json-schema/reference/string.html#built-in-formats 31 | */ 32 | private mapStringFormat(zodString: ZodString): string | undefined { 33 | if (zodString.isUUID) return 'uuid'; 34 | if (zodString.isEmail) return 'email'; 35 | if (zodString.isURL) return 'uri'; 36 | if (zodString.isDate) return 'date'; 37 | if (zodString.isDatetime) return 'date-time'; 38 | if (zodString.isCUID) return 'cuid'; 39 | if (zodString.isCUID2) return 'cuid2'; 40 | if (zodString.isULID) return 'ulid'; 41 | if (zodString.isIP) return 'ip'; 42 | if (zodString.isEmoji) return 'emoji'; 43 | 44 | return undefined; 45 | } 46 | 47 | private getZodStringCheck( 48 | zodString: ZodString, 49 | kind: T 50 | ) { 51 | return zodString._def.checks.find( 52 | ( 53 | check 54 | ): check is Extract< 55 | ZodStringDef['checks'][number], 56 | { kind: typeof kind } 57 | > => { 58 | return check.kind === kind; 59 | } 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/transformers/tuple.ts: -------------------------------------------------------------------------------- 1 | import { MapNullableType, MapSubSchema, SchemaObject } from '../types'; 2 | import { ZodTuple } from 'zod'; 3 | import { OpenApiVersionSpecifics } from '../openapi-generator'; 4 | 5 | export class TupleTransformer { 6 | constructor(private versionSpecifics: OpenApiVersionSpecifics) {} 7 | 8 | transform( 9 | zodSchema: ZodTuple, 10 | mapNullableType: MapNullableType, 11 | mapItem: MapSubSchema 12 | ): SchemaObject { 13 | const { items } = zodSchema._def; 14 | 15 | const schemas = items.map(mapItem); 16 | 17 | return { 18 | ...mapNullableType('array'), 19 | ...this.versionSpecifics.mapTupleItems(schemas), 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/transformers/union.ts: -------------------------------------------------------------------------------- 1 | import { ZodTypeAny, ZodUnion } from 'zod'; 2 | import { MapNullableOfArray, MapSubSchema } from '../types'; 3 | import { isZodType } from '../lib/zod-is-type'; 4 | 5 | export class UnionTransformer { 6 | transform( 7 | zodSchema: ZodUnion, 8 | mapNullableOfArray: MapNullableOfArray, 9 | mapItem: MapSubSchema 10 | ) { 11 | const options = this.flattenUnionTypes(zodSchema); 12 | 13 | const schemas = options.map(schema => { 14 | // If any of the underlying schemas of a union is .nullable then the whole union 15 | // would be nullable. `mapNullableOfArray` would place it where it belongs. 16 | // Therefor we are stripping the additional nullables from the inner schemas 17 | // See https://github.com/asteasolutions/zod-to-openapi/issues/149 18 | const optionToGenerate = this.unwrapNullable(schema); 19 | 20 | return mapItem(optionToGenerate); 21 | }); 22 | 23 | return { 24 | anyOf: mapNullableOfArray(schemas), 25 | }; 26 | } 27 | 28 | private flattenUnionTypes(schema: ZodTypeAny): ZodTypeAny[] { 29 | if (!isZodType(schema, 'ZodUnion')) { 30 | return [schema]; 31 | } 32 | 33 | const options = schema._def.options as ZodTypeAny[]; 34 | 35 | return options.flatMap(option => this.flattenUnionTypes(option)); 36 | } 37 | 38 | private unwrapNullable(schema: ZodTypeAny): ZodTypeAny { 39 | if (isZodType(schema, 'ZodNullable')) { 40 | return this.unwrapNullable(schema.unwrap()); 41 | } 42 | return schema; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ZodBigIntCheck, ZodNumberCheck, ZodTypeAny } from 'zod'; 2 | import type { 3 | ReferenceObject as ReferenceObject30, 4 | ParameterObject as ParameterObject30, 5 | RequestBodyObject as RequestBodyObject30, 6 | PathItemObject as PathItemObject30, 7 | OpenAPIObject as OpenAPIObject30, 8 | ComponentsObject as ComponentsObject30, 9 | ParameterLocation as ParameterLocation30, 10 | ResponseObject as ResponseObject30, 11 | ContentObject as ContentObject30, 12 | DiscriminatorObject as DiscriminatorObject30, 13 | SchemaObject as SchemaObject30, 14 | BaseParameterObject as BaseParameterObject30, 15 | HeadersObject as HeadersObject30, 16 | } from 'openapi3-ts/oas30'; 17 | import type { 18 | ReferenceObject as ReferenceObject31, 19 | ParameterObject as ParameterObject31, 20 | RequestBodyObject as RequestBodyObject31, 21 | PathItemObject as PathItemObject31, 22 | OpenAPIObject as OpenAPIObject31, 23 | ComponentsObject as ComponentsObject31, 24 | ParameterLocation as ParameterLocation31, 25 | ResponseObject as ResponseObject31, 26 | ContentObject as ContentObject31, 27 | DiscriminatorObject as DiscriminatorObject31, 28 | SchemaObject as SchemaObject31, 29 | BaseParameterObject as BaseParameterObject31, 30 | HeadersObject as HeadersObject31, 31 | } from 'openapi3-ts/oas31'; 32 | 33 | export type ZodNumericCheck = ZodNumberCheck | ZodBigIntCheck; 34 | 35 | export type ReferenceObject = ReferenceObject30 & ReferenceObject31; 36 | export type ParameterObject = ParameterObject30 & ParameterObject31; 37 | export type RequestBodyObject = RequestBodyObject30 & RequestBodyObject31; 38 | export type PathItemObject = PathItemObject30 & PathItemObject31; 39 | export type OpenAPIObject = OpenAPIObject30 & OpenAPIObject31; 40 | export type ComponentsObject = ComponentsObject30 & ComponentsObject31; 41 | export type ParameterLocation = ParameterLocation30 & ParameterLocation31; 42 | export type ResponseObject = ResponseObject30 & ResponseObject31; 43 | export type ContentObject = ContentObject30 & ContentObject31; 44 | export type DiscriminatorObject = DiscriminatorObject30 & DiscriminatorObject31; 45 | export type SchemaObject = SchemaObject30 & SchemaObject31; 46 | export type BaseParameterObject = BaseParameterObject30 & BaseParameterObject31; 47 | export type HeadersObject = HeadersObject30 & HeadersObject31; 48 | 49 | export type MapNullableType = ( 50 | type: NonNullable | undefined 51 | ) => Pick; 52 | 53 | export type MapNullableTypeWithNullable = ( 54 | type: NonNullable | undefined, 55 | isNullable: boolean 56 | ) => Pick; 57 | 58 | export type MapNullableOfArray = ( 59 | objects: (SchemaObject | ReferenceObject)[] 60 | ) => (SchemaObject | ReferenceObject)[]; 61 | 62 | export type MapNullableOfArrayWithNullable = ( 63 | objects: (SchemaObject | ReferenceObject)[], 64 | isNullable: boolean 65 | ) => (SchemaObject | ReferenceObject)[]; 66 | 67 | export type GetNumberChecks = ( 68 | checks: ZodNumericCheck[] 69 | ) => Pick< 70 | SchemaObject, 71 | 'minimum' | 'exclusiveMinimum' | 'maximum' | 'exclusiveMaximum' 72 | >; 73 | 74 | export type MapSubSchema = ( 75 | zodSchema: ZodTypeAny 76 | ) => SchemaObject | ReferenceObject; 77 | -------------------------------------------------------------------------------- /src/v3.0/openapi-generator.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIObject } from 'openapi3-ts/oas30'; 2 | 3 | import { OpenAPIGenerator, OpenApiVersion } from '../openapi-generator'; 4 | import { ZodSchema } from 'zod'; 5 | import { OpenApiGeneratorV30Specifics } from './specifics'; 6 | import { OpenAPIDefinitions } from '../openapi-registry'; 7 | 8 | export type OpenAPIObjectConfig = Omit< 9 | OpenAPIObject, 10 | 'paths' | 'components' | 'webhooks' 11 | >; 12 | 13 | export class OpenApiGeneratorV3 { 14 | private generator; 15 | 16 | constructor(definitions: (OpenAPIDefinitions | ZodSchema)[]) { 17 | const specifics = new OpenApiGeneratorV30Specifics(); 18 | this.generator = new OpenAPIGenerator(definitions, specifics); 19 | } 20 | 21 | generateDocument(config: OpenAPIObjectConfig): OpenAPIObject { 22 | const baseData = this.generator.generateDocumentData(); 23 | 24 | return { 25 | ...config, 26 | ...baseData, 27 | }; 28 | } 29 | 30 | generateComponents(): Pick { 31 | return this.generator.generateComponents(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/v3.0/specifics.ts: -------------------------------------------------------------------------------- 1 | import type { ReferenceObject, SchemaObject } from 'openapi3-ts/oas30'; 2 | import { OpenApiVersionSpecifics } from '../openapi-generator'; 3 | import { ZodNumericCheck, SchemaObject as CommonSchemaObject } from '../types'; 4 | import { uniq } from '../lib/lodash'; 5 | 6 | export class OpenApiGeneratorV30Specifics implements OpenApiVersionSpecifics { 7 | get nullType() { 8 | return { nullable: true }; 9 | } 10 | 11 | mapNullableOfArray( 12 | objects: (SchemaObject | ReferenceObject)[], 13 | isNullable: boolean 14 | ): (SchemaObject | ReferenceObject)[] { 15 | if (isNullable) { 16 | return [...objects, this.nullType]; 17 | } 18 | return objects; 19 | } 20 | 21 | mapNullableType( 22 | type: NonNullable | undefined, 23 | isNullable: boolean 24 | ): Pick { 25 | return { 26 | ...(type ? { type } : undefined), 27 | ...(isNullable ? this.nullType : undefined), 28 | }; 29 | } 30 | 31 | mapTupleItems(schemas: (CommonSchemaObject | ReferenceObject)[]) { 32 | const uniqueSchemas = uniq(schemas); 33 | 34 | return { 35 | items: 36 | uniqueSchemas.length === 1 37 | ? uniqueSchemas[0] 38 | : { anyOf: uniqueSchemas }, 39 | minItems: schemas.length, 40 | maxItems: schemas.length, 41 | }; 42 | } 43 | 44 | getNumberChecks( 45 | checks: ZodNumericCheck[] 46 | ): Pick< 47 | SchemaObject, 48 | 'minimum' | 'exclusiveMinimum' | 'maximum' | 'exclusiveMaximum' 49 | > { 50 | return Object.assign( 51 | {}, 52 | ...checks.map(check => { 53 | switch (check.kind) { 54 | case 'min': 55 | return check.inclusive 56 | ? { minimum: Number(check.value) } 57 | : { minimum: Number(check.value), exclusiveMinimum: true }; 58 | 59 | case 'max': 60 | return check.inclusive 61 | ? { maximum: Number(check.value) } 62 | : { maximum: Number(check.value), exclusiveMaximum: true }; 63 | 64 | default: 65 | return {}; 66 | } 67 | }) 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/v3.1/openapi-generator.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIObject, PathItemObject } from 'openapi3-ts/oas31'; 2 | 3 | import { OpenAPIGenerator, OpenApiVersion } from '../openapi-generator'; 4 | import { ZodSchema } from 'zod'; 5 | import { OpenApiGeneratorV31Specifics } from './specifics'; 6 | import { 7 | OpenAPIDefinitions, 8 | RouteConfig, 9 | WebhookDefinition, 10 | } from '../openapi-registry'; 11 | 12 | function isWebhookDefinition( 13 | definition: OpenAPIDefinitions | ZodSchema 14 | ): definition is WebhookDefinition { 15 | return 'type' in definition && definition.type === 'webhook'; 16 | } 17 | 18 | export type OpenAPIObjectConfigV31 = Omit< 19 | OpenAPIObject, 20 | 'paths' | 'components' | 'webhooks' 21 | >; 22 | 23 | export class OpenApiGeneratorV31 { 24 | private generator; 25 | private webhookRefs: Record = {}; 26 | 27 | constructor(private definitions: (OpenAPIDefinitions | ZodSchema)[]) { 28 | const specifics = new OpenApiGeneratorV31Specifics(); 29 | this.generator = new OpenAPIGenerator(this.definitions, specifics); 30 | } 31 | 32 | generateDocument(config: OpenAPIObjectConfigV31): OpenAPIObject { 33 | const baseDocument = this.generator.generateDocumentData(); 34 | 35 | this.definitions 36 | .filter(isWebhookDefinition) 37 | .forEach(definition => this.generateSingleWebhook(definition.webhook)); 38 | 39 | return { 40 | ...config, 41 | ...baseDocument, 42 | webhooks: this.webhookRefs, 43 | }; 44 | } 45 | 46 | generateComponents(): Pick { 47 | return this.generator.generateComponents(); 48 | } 49 | 50 | private generateSingleWebhook(route: RouteConfig): PathItemObject { 51 | const routeDoc = this.generator.generatePath(route) as PathItemObject; 52 | 53 | this.webhookRefs[route.path] = { 54 | ...this.webhookRefs[route.path], 55 | ...routeDoc, 56 | }; 57 | 58 | return routeDoc; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/v3.1/specifics.ts: -------------------------------------------------------------------------------- 1 | import type { ReferenceObject, SchemaObject } from 'openapi3-ts/oas31'; 2 | 3 | import { OpenApiVersionSpecifics } from '../openapi-generator'; 4 | import { ZodNumericCheck, SchemaObject as CommonSchemaObject } from '../types'; 5 | 6 | export class OpenApiGeneratorV31Specifics implements OpenApiVersionSpecifics { 7 | get nullType() { 8 | return { type: 'null' } as const; 9 | } 10 | 11 | mapNullableOfArray( 12 | objects: (SchemaObject | ReferenceObject)[], 13 | isNullable: boolean 14 | ): (SchemaObject | ReferenceObject)[] { 15 | if (isNullable) { 16 | return [...objects, this.nullType]; 17 | } 18 | return objects; 19 | } 20 | 21 | mapNullableType( 22 | type: NonNullable | undefined, 23 | isNullable: boolean 24 | ): { type?: SchemaObject['type'] } { 25 | if (!type) { 26 | // 'null' is considered a type in Open API 3.1.0 => not providing a type includes null 27 | return {}; 28 | } 29 | 30 | // Open API 3.1.0 made the `nullable` key invalid and instead you use type arrays 31 | if (isNullable) { 32 | return { 33 | type: Array.isArray(type) ? [...type, 'null'] : [type, 'null'], 34 | }; 35 | } 36 | 37 | return { 38 | type, 39 | }; 40 | } 41 | 42 | mapTupleItems(schemas: (CommonSchemaObject | ReferenceObject)[]) { 43 | return { 44 | prefixItems: schemas, 45 | }; 46 | } 47 | 48 | getNumberChecks( 49 | checks: ZodNumericCheck[] 50 | ): Pick< 51 | SchemaObject, 52 | 'minimum' | 'exclusiveMinimum' | 'maximum' | 'exclusiveMaximum' 53 | > { 54 | return Object.assign( 55 | {}, 56 | ...checks.map(check => { 57 | switch (check.kind) { 58 | case 'min': 59 | return check.inclusive 60 | ? { minimum: Number(check.value) } 61 | : { exclusiveMinimum: Number(check.value) }; 62 | 63 | case 'max': 64 | return check.inclusive 65 | ? { maximum: Number(check.value) } 66 | : { exclusiveMaximum: Number(check.value) }; 67 | 68 | default: 69 | return {}; 70 | } 71 | }) 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/zod-extensions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ParameterObject as ParameterObject30, 3 | SchemaObject as SchemaObject30, 4 | } from 'openapi3-ts/oas30'; 5 | import { 6 | ParameterObject as ParameterObject31, 7 | SchemaObject as SchemaObject31, 8 | } from 'openapi3-ts/oas31'; 9 | import type { ZodObject, ZodRawShape, ZodTypeAny, z } from 'zod'; 10 | import { isZodType } from './lib/zod-is-type'; 11 | 12 | type ExampleValue = T extends Date ? string : T; 13 | 14 | type ParameterObject = ParameterObject30 | ParameterObject31; 15 | type SchemaObject = SchemaObject30 | SchemaObject31; 16 | 17 | export type ZodOpenAPIMetadata> = Omit< 18 | SchemaObject, 19 | 'example' | 'examples' | 'default' 20 | > & { 21 | param?: Partial & { example?: E }; 22 | example?: E; 23 | examples?: E[]; 24 | default?: T; 25 | }; 26 | 27 | export interface ZodOpenAPIInternalMetadata { 28 | refId?: string; 29 | extendedFrom?: { refId: string; schema: ZodObject }; 30 | } 31 | 32 | export interface ZodOpenApiFullMetadata { 33 | _internal?: ZodOpenAPIInternalMetadata; 34 | metadata?: ZodOpenAPIMetadata; 35 | } 36 | 37 | declare module 'zod' { 38 | interface ZodTypeDef { 39 | openapi?: ZodOpenApiFullMetadata; 40 | } 41 | 42 | interface ZodType< 43 | Output = any, 44 | Def extends ZodTypeDef = ZodTypeDef, 45 | Input = Output 46 | > { 47 | openapi( 48 | this: T, 49 | metadata: Partial>> 50 | ): T; 51 | 52 | openapi( 53 | this: T, 54 | refId: string, 55 | metadata?: Partial>> 56 | ): T; 57 | } 58 | } 59 | 60 | function preserveMetadataFromModifier( 61 | zod: typeof z, 62 | modifier: keyof typeof z.ZodType.prototype 63 | ) { 64 | const zodModifier = zod.ZodType.prototype[modifier]; 65 | (zod.ZodType.prototype[modifier] as any) = function ( 66 | this: any, 67 | ...args: any[] 68 | ) { 69 | const result = zodModifier.apply(this, args); 70 | result._def.openapi = this._def.openapi; 71 | 72 | return result; 73 | }; 74 | } 75 | 76 | export function extendZodWithOpenApi(zod: typeof z) { 77 | if (typeof zod.ZodType.prototype.openapi !== 'undefined') { 78 | // This zod instance is already extended with the required methods, 79 | // doing it again will just result in multiple wrapper methods for 80 | // `optional` and `nullable` 81 | return; 82 | } 83 | 84 | zod.ZodType.prototype.openapi = function ( 85 | refOrOpenapi: string | Partial>, 86 | metadata?: Partial> 87 | ) { 88 | const openapi = typeof refOrOpenapi === 'string' ? metadata : refOrOpenapi; 89 | 90 | const { param, ...restOfOpenApi } = openapi ?? {}; 91 | 92 | const _internal = { 93 | ...this._def.openapi?._internal, 94 | ...(typeof refOrOpenapi === 'string' 95 | ? { refId: refOrOpenapi } 96 | : undefined), 97 | }; 98 | 99 | const resultMetadata = { 100 | ...this._def.openapi?.metadata, 101 | ...restOfOpenApi, 102 | ...(this._def.openapi?.metadata?.param || param 103 | ? { 104 | param: { 105 | ...this._def.openapi?.metadata?.param, 106 | ...param, 107 | }, 108 | } 109 | : undefined), 110 | }; 111 | 112 | const result = new (this as any).constructor({ 113 | ...this._def, 114 | openapi: { 115 | ...(Object.keys(_internal).length > 0 ? { _internal } : undefined), 116 | ...(Object.keys(resultMetadata).length > 0 117 | ? { metadata: resultMetadata } 118 | : undefined), 119 | }, 120 | }); 121 | 122 | if (isZodType(this, 'ZodObject')) { 123 | const originalExtend = this.extend; 124 | 125 | result.extend = function (...args: any) { 126 | const extendedResult = originalExtend.apply(this, args); 127 | 128 | extendedResult._def.openapi = { 129 | _internal: { 130 | extendedFrom: this._def.openapi?._internal?.refId 131 | ? { refId: this._def.openapi?._internal?.refId, schema: this } 132 | : this._def.openapi?._internal.extendedFrom, 133 | }, 134 | metadata: extendedResult._def.openapi?.metadata, 135 | }; 136 | 137 | return extendedResult; 138 | }; 139 | } 140 | 141 | return result; 142 | }; 143 | 144 | preserveMetadataFromModifier(zod, 'optional'); 145 | preserveMetadataFromModifier(zod, 'nullable'); 146 | preserveMetadataFromModifier(zod, 'default'); 147 | 148 | preserveMetadataFromModifier(zod, 'transform'); 149 | preserveMetadataFromModifier(zod, 'refine'); 150 | 151 | const zodDeepPartial = zod.ZodObject.prototype.deepPartial; 152 | zod.ZodObject.prototype.deepPartial = function (this: any) { 153 | const initialShape = this._def.shape(); 154 | 155 | const result = zodDeepPartial.apply(this); 156 | 157 | const resultShape = result._def.shape(); 158 | 159 | Object.entries(resultShape).forEach(([key, value]) => { 160 | value._def.openapi = initialShape[key]?._def?.openapi; 161 | }); 162 | 163 | result._def.openapi = undefined; 164 | 165 | return result; 166 | }; 167 | 168 | const zodPick = zod.ZodObject.prototype.pick as any; 169 | zod.ZodObject.prototype.pick = function (this: any, ...args: any[]) { 170 | const result = zodPick.apply(this, args); 171 | result._def.openapi = undefined; 172 | 173 | return result; 174 | }; 175 | 176 | const zodOmit = zod.ZodObject.prototype.omit as any; 177 | zod.ZodObject.prototype.omit = function (this: any, ...args: any[]) { 178 | const result = zodOmit.apply(this, args); 179 | result._def.openapi = undefined; 180 | 181 | return result; 182 | }; 183 | } 184 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | "emitDeclarationOnly": true, 14 | "declaration": true /* Generates corresponding '.d.ts' file. */, 15 | "declarationMap": false /* Generates a sourcemap for each corresponding '.d.ts' file. */, 16 | "sourceMap": false /* Generates corresponding '.map' file. */, 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | "outDir": "./dist" /* Redirect output structure to the directory. */, 19 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true /* Enable all strict type-checking options. */, 30 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 42 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 43 | "noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */, 44 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 45 | "noPropertyAccessFromIndexSignature": true /* Require undeclared properties from index signatures to use element accesses. */, 46 | 47 | /* Module Resolution Options */ 48 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 49 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 50 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 51 | "rootDirs": [ 52 | "./src" 53 | ] /* List of root folders whose combined content represents the structure of the project at runtime. */, 54 | // "typeRoots": [], /* List of folders to include type definitions from. */ 55 | // "types": [], /* Type declaration files to be included in compilation. */ 56 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 57 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 58 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 59 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 60 | 61 | /* Source Map Options */ 62 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 63 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 64 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 65 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 66 | 67 | /* Experimental Options */ 68 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 69 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 70 | 71 | /* Advanced Options */ 72 | "skipLibCheck": true /* Skip type checking of declaration files. */, 73 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 74 | } 75 | } 76 | --------------------------------------------------------------------------------