diff --git a/packages/zod-nestjs/src/lib/create-zod-dto.ts b/packages/zod-nestjs/src/lib/create-zod-dto.ts index 91402c2..9abbf54 100644 --- a/packages/zod-nestjs/src/lib/create-zod-dto.ts +++ b/packages/zod-nestjs/src/lib/create-zod-dto.ts @@ -1,4 +1,4 @@ -import type { SchemaObject } from 'openapi3-ts/oas31'; +import type { SchemaObject } from 'openapi3-ts/oas30'; import { generateSchema, OpenApiZodAny } from '@anatine/zod-openapi'; import * as z from 'zod'; diff --git a/packages/zod-nestjs/src/lib/patch-nest-swagger.ts b/packages/zod-nestjs/src/lib/patch-nest-swagger.ts index be285ad..f5d8148 100644 --- a/packages/zod-nestjs/src/lib/patch-nest-swagger.ts +++ b/packages/zod-nestjs/src/lib/patch-nest-swagger.ts @@ -6,7 +6,7 @@ * https://github.com/kbkk/abitia/blob/master/packages/zod-dto/src/OpenApi/patchNestjsSwagger.ts */ import {generateSchema} from '@anatine/zod-openapi'; -import type {SchemaObject} from 'openapi3-ts/oas31'; +import type {SchemaObject} from 'openapi3-ts/oas30'; interface Type extends Function { new (...args: any[]): T; diff --git a/packages/zod-openapi/src/lib/zod-extensions.spec.ts b/packages/zod-openapi/src/lib/zod-extensions.spec.ts index e003ce3..af34528 100644 --- a/packages/zod-openapi/src/lib/zod-extensions.spec.ts +++ b/packages/zod-openapi/src/lib/zod-extensions.spec.ts @@ -12,7 +12,6 @@ describe('Zod Extensions', () => { const schema = z.object({ one: z.string().openapi({example: 'oneOne'}), two: z.number(), - }).openapi({example: {one: 'oneOne', two: 42}}) const apiSchema = generateSchema(schema); diff --git a/packages/zod-openapi/src/lib/zod-extensions.ts b/packages/zod-openapi/src/lib/zod-extensions.ts index b17f3ac..452b12a 100644 --- a/packages/zod-openapi/src/lib/zod-extensions.ts +++ b/packages/zod-openapi/src/lib/zod-extensions.ts @@ -4,7 +4,7 @@ This code is heavily inspired by https://github.com/asteasolutions/zod-to-openap import { extendApi } from './zod-openapi'; import {z} from "zod"; -import { SchemaObject } from "openapi3-ts/oas31"; +import { SchemaObject } from "openapi3-ts/oas30"; import {ZodTypeDef} from "zod/lib/types"; diff --git a/packages/zod-openapi/src/lib/zod-openapi.spec.ts b/packages/zod-openapi/src/lib/zod-openapi.spec.ts index 77c6e49..4e53e39 100644 --- a/packages/zod-openapi/src/lib/zod-openapi.spec.ts +++ b/packages/zod-openapi/src/lib/zod-openapi.spec.ts @@ -1,7 +1,7 @@ -import { SchemaObject } from 'openapi3-ts/oas31'; +import { SchemaObject } from 'openapi3-ts/oas30'; import validator from 'validator'; import { z } from 'zod'; -import { generateSchema, extendApi } from './zod-openapi'; +import { generateSchema, extendApi, fragmentName, generateVocabulary } from './zod-openapi'; describe('zodOpenapi', () => { /** @@ -68,7 +68,7 @@ describe('zodOpenapi', () => { ); const schemaIn = generateSchema(zodTransform); expect(schemaIn.type).toEqual('string'); - const schemaOut = generateSchema(zodTransform, true); + const schemaOut = generateSchema(zodTransform, {useOutput: true}) as SchemaObject; expect(schemaOut.type).toEqual('number'); }); @@ -197,12 +197,12 @@ describe('zodOpenapi', () => { aNumberMin: { type: 'number', minimum: 3 }, aNumberMax: { type: 'number', maximum: 8 }, aNumberInt: { type: 'integer' }, - aNumberPositive: { type: 'number', minimum: 0, exclusiveMinimum: 0 }, + aNumberPositive: { type: 'number', minimum: 0, exclusiveMinimum: true }, aNumberNonnegative: { type: 'number', minimum: 0 }, - aNumberNegative: { type: 'number', maximum: 0, exclusiveMaximum: 0 }, + aNumberNegative: { type: 'number', maximum: 0, exclusiveMaximum: true }, aNumberNonpositive: { type: 'number', maximum: 0 }, - aNumberGt: { type: 'number', minimum: 5, exclusiveMinimum: 5 }, - aNumberLt: { type: 'number', maximum: 5, exclusiveMaximum: 5 }, + aNumberGt: { type: 'number', minimum: 5, exclusiveMinimum: true }, + aNumberLt: { type: 'number', maximum: 5, exclusiveMaximum: true }, aNumberMultipleOf: { type: 'number', multipleOf: 2 }, }, description: 'Look mah, the horse can count higher than me!', @@ -521,7 +521,7 @@ describe('zodOpenapi', () => { .passthrough(), }); - const schemaTest = generateSchema(zodSchema, true); + const schemaTest = generateSchema(zodSchema, {useOutput: true}); expect(schemaTest).toEqual({ type: 'object', @@ -853,5 +853,49 @@ describe('zodOpenapi', () => { } ] }); - }) + }); + + it('Ignores components out of its vocabulary', () => { + const schema = extendApi(z.string(), { + [fragmentName]: "coolstring" + }); + + expect(generateSchema(schema)) + .toEqual({"type": "string"}); + expect(generateSchema(z.object({string: schema}))) + .toEqual({ + "properties": { + "string": { + "type": "string" + } + }, + "required": ["string"], + "type": "object" + }); + }); + + it('Elides components in its vocabulary', () => { + const schema = extendApi(z.string(), { + [fragmentName]: "coolstring" + }); + + const [schemas, vocabulary] = generateVocabulary([schema]); + + expect(schemas) + .toEqual({"coolstring": {"type": "string"}}); + + expect(generateSchema(schema, { vocabulary })) + .toEqual({"$ref": "#/components/schemas/coolstring"}); + + expect(generateSchema(z.object({string: schema}), { vocabulary })) + .toEqual({ + "properties": { + "string": { + "$ref": "#/components/schemas/coolstring" + } + }, + "required": ["string"], + "type": "object" + }); + }); }); diff --git a/packages/zod-openapi/src/lib/zod-openapi.ts b/packages/zod-openapi/src/lib/zod-openapi.ts index ade673c..deb3cd3 100644 --- a/packages/zod-openapi/src/lib/zod-openapi.ts +++ b/packages/zod-openapi/src/lib/zod-openapi.ts @@ -1,51 +1,58 @@ -import type {SchemaObject, SchemaObjectType} from 'openapi3-ts/oas31'; +import type {ReferenceObject, SchemaObject, SchemaObjectType} from 'openapi3-ts/oas30'; import merge from 'ts-deepmerge'; import {AnyZodObject, z, ZodTypeAny} from 'zod'; +type SchemaObjectWithName = SchemaObject & {[fragmentName]?: string}; + export interface OpenApiZodAny extends ZodTypeAny { - metaOpenApi?: SchemaObject | SchemaObject[]; + metaOpenApi?: SchemaObjectWithName | SchemaObjectWithName[]; } interface OpenApiZodAnyObject extends AnyZodObject { - metaOpenApi?: SchemaObject | SchemaObject[]; + metaOpenApi?: SchemaObjectWithName | SchemaObjectWithName[]; } interface ParsingArgs { zodRef: T; - schemas: SchemaObject[]; - useOutput?: boolean; + schemas: SchemaObjectWithName[]; + options?: {useOutput?: boolean; vocabulary?: Set;} } export function extendApi( schema: T, - SchemaObject: SchemaObject = {} + schemaObject: SchemaObjectWithName = {} ): T { - schema.metaOpenApi = Object.assign(schema.metaOpenApi || {}, SchemaObject); + schema.metaOpenApi = Object.assign(schema.metaOpenApi || {}, schemaObject); return schema; } function iterateZodObject({ zodRef, - useOutput, + options, }: ParsingArgs) { return Object.keys(zodRef.shape).reduce( (carry, key) => ({ ...carry, - [key]: generateSchema(zodRef.shape[key], useOutput), + [key]: generateSchema(zodRef.shape[key], options), }), - {} as Record + {} as Record ); } +function dropFragmentNames(schemas: SchemaObjectWithName[]) { + return schemas.map(schema => ({...schema, [fragmentName]: undefined})); +} + function parseTransformation({ zodRef, schemas, - useOutput, + options, }: ParsingArgs | z.ZodEffects>): SchemaObject { - const input = generateSchema(zodRef._def.schema, useOutput); + // we need to get the specific schema for the transformation + const input = generateSchema(zodRef._def.schema, {...options, vocabulary: undefined}) as SchemaObject; let output = 'undefined'; - if (useOutput && zodRef._def.effect) { + if (options?.useOutput && zodRef._def.effect) { const effect = zodRef._def.effect.type === 'transform' ? zodRef._def.effect : null; if (effect && 'transform' in effect) { @@ -81,7 +88,7 @@ function parseTransformation({ } : {}), }, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -128,7 +135,7 @@ function parseString({ return merge( baseSchema, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -145,11 +152,11 @@ function parseNumber({ case 'max': baseSchema.maximum = item.value; // TODO: option to make this always explicit? (false instead of non-existent) - if (!item.inclusive) baseSchema.exclusiveMaximum = item.value; + if (!item.inclusive) baseSchema.exclusiveMaximum = true; break; case 'min': baseSchema.minimum = item.value; - if (!item.inclusive) baseSchema.exclusiveMinimum = item.value; + if (!item.inclusive) baseSchema.exclusiveMinimum = true; break; case 'int': baseSchema.type = 'integer'; @@ -161,14 +168,14 @@ function parseNumber({ return merge( baseSchema, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } function parseObject({ zodRef, schemas, - useOutput, + options, }: ParsingArgs< z.ZodObject >): SchemaObject { @@ -181,7 +188,7 @@ function parseObject({ zodRef._def.catchall?._def.typeName === 'ZodNever' ) ) - additionalProperties = generateSchema(zodRef._def.catchall, useOutput); + additionalProperties = generateSchema(zodRef._def.catchall, options); else if (zodRef._def.unknownKeys === 'passthrough') additionalProperties = true; else if (zodRef._def.unknownKeys === 'strict') additionalProperties = false; @@ -212,20 +219,20 @@ function parseObject({ properties: iterateZodObject({ zodRef: zodRef as OpenApiZodAnyObject, schemas, - useOutput, + options, }), ...required, ...additionalProperties, }, zodRef.description ? {description: zodRef.description} : {}, - ...schemas + ...dropFragmentNames(schemas) ); } function parseRecord({ zodRef, schemas, - useOutput, + options, }: ParsingArgs): SchemaObject { return merge( { @@ -233,10 +240,10 @@ function parseRecord({ additionalProperties: zodRef._def.valueType instanceof z.ZodUnknown ? {} - : generateSchema(zodRef._def.valueType, useOutput), + : generateSchema(zodRef._def.valueType, options), }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -247,7 +254,7 @@ function parseBigInt({ return merge( { type: 'integer' as SchemaObjectType, format: 'int64' }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -258,7 +265,7 @@ function parseBoolean({ return merge( { type: 'boolean' as SchemaObjectType }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -266,7 +273,7 @@ function parseDate({ zodRef, schemas }: ParsingArgs): SchemaObject { return merge( { type: 'string' as SchemaObjectType, format: 'date-time' }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -278,43 +285,43 @@ function parseNull({ zodRef, schemas }: ParsingArgs): SchemaObject { nullable: true, }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } function parseOptionalNullable({ schemas, zodRef, - useOutput, + options, }: ParsingArgs< z.ZodOptional | z.ZodNullable >): SchemaObject { return merge( - generateSchema(zodRef.unwrap(), useOutput), + generateSchema(zodRef.unwrap(), options), zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } function parseDefault({ schemas, zodRef, - useOutput, + options, }: ParsingArgs>): SchemaObject { return merge( { default: zodRef._def.defaultValue(), - ...generateSchema(zodRef._def.innerType, useOutput), + ...generateSchema(zodRef._def.innerType, options), }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } function parseArray({ schemas, zodRef, - useOutput, + options, }: ParsingArgs>): SchemaObject { const constraints: SchemaObject = {}; if (zodRef._def.exactLength != null) { @@ -330,11 +337,11 @@ function parseArray({ return merge( { type: 'array' as SchemaObjectType, - items: generateSchema(zodRef.element, useOutput), + items: generateSchema(zodRef.element, options), ...constraints, }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -348,7 +355,7 @@ function parseLiteral({ enum: [zodRef._def.value], }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -362,31 +369,31 @@ function parseEnum({ enum: Object.values(zodRef._def.values), }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } function parseIntersection({ schemas, zodRef, - useOutput, + options, }: ParsingArgs>): SchemaObject { return merge( { allOf: [ - generateSchema(zodRef._def.left, useOutput), - generateSchema(zodRef._def.right, useOutput), + generateSchema(zodRef._def.left, options), + generateSchema(zodRef._def.right, options), ], }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } function parseUnion({ schemas, zodRef, - useOutput, + options, }: ParsingArgs>): SchemaObject { const contents = zodRef._def.options; if (contents.reduce((prev, content) => prev && content._def.typeName === 'ZodLiteral', true)) { @@ -414,17 +421,17 @@ function parseUnion({ return merge( { - oneOf: contents.map((schema) => generateSchema(schema, useOutput)), + oneOf: contents.map((schema) => generateSchema(schema, options)), }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } function parseDiscriminatedUnion({ schemas, zodRef, - useOutput, + options, }: ParsingArgs< z.ZodDiscriminatedUnion[]> >): SchemaObject { @@ -445,10 +452,10 @@ function parseDiscriminatedUnion({ z.ZodDiscriminatedUnionOption[] > )._def.options.values() - ).map((schema) => generateSchema(schema, useOutput)), + ).map((schema) => generateSchema(schema, options)), }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -459,7 +466,7 @@ function parseNever({ return merge( { readOnly: true }, zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -467,7 +474,7 @@ function parseBranded({ schemas, zodRef, }: ParsingArgs>): SchemaObject { - return merge(generateSchema(zodRef._def.type), ...schemas); + return merge(generateSchema(zodRef._def.type), ...dropFragmentNames(schemas)); } function catchAllParser({ @@ -476,7 +483,7 @@ function catchAllParser({ }: ParsingArgs): SchemaObject { return merge( zodRef.description ? { description: zodRef.description } : {}, - ...schemas + ...dropFragmentNames(schemas) ); } @@ -517,15 +524,39 @@ const workerMap = { }; type WorkerKeys = keyof typeof workerMap; -export function generateSchema( - zodRef: OpenApiZodAny, - useOutput?: boolean -): SchemaObject { +function getSchemasForZodObject(zodRef: OpenApiZodAny): SchemaObjectWithName[] { const { metaOpenApi = {} } = zodRef; - const schemas = [ + return [ zodRef.isNullable && zodRef.isNullable() ? { nullable: true } : {}, ...(Array.isArray(metaOpenApi) ? metaOpenApi : [metaOpenApi]), ]; +} + +function getNameFromSchemas(schemas: SchemaObjectWithName[]): string | undefined { + return schemas.reduce( + (prev, schema) => prev || schema[fragmentName], undefined as string | undefined + ); +} + +export function generateSchema(zodRef: OpenApiZodAny): SchemaObject; +export function generateSchema(zodRef: OpenApiZodAny, options?: {useOutput?: boolean, vocabulary?: Set}): SchemaObject | ReferenceObject; +/** + * @deprecated Use generateSchema(zodRef, {useOutput}) instead. + */ +export function generateSchema(zodRef: OpenApiZodAny, useOutput: boolean): SchemaObject; +export function generateSchema( + zodRef: OpenApiZodAny, + second?: {useOutput?: boolean, vocabulary?: Set} | boolean +): SchemaObject | ReferenceObject { + const options = typeof second === 'boolean' ? {useOutput: second} : second; + const schemas = getSchemasForZodObject(zodRef); + const name = getNameFromSchemas(schemas); + + if (name && options?.vocabulary?.has(name)) { + return { + '$ref': `#/components/schemas/${name}` + }; + } try { const typeName = zodRef._def.typeName as WorkerKeys; @@ -533,13 +564,33 @@ export function generateSchema( return workerMap[typeName]({ zodRef: zodRef as never, schemas, - useOutput, + options, }); } - return catchAllParser({ zodRef, schemas }); + return catchAllParser({ zodRef, schemas, options }); } catch (err) { console.error(err); - return catchAllParser({ zodRef, schemas }); + return catchAllParser({ zodRef, schemas, options }); } } + +export const fragmentName = Symbol('fragmentName'); + +export function generateVocabulary(objects: OpenApiZodAny[]): [{[key: string]: SchemaObject}, Set] { + const fragments = objects.filter(object => getNameFromSchemas(getSchemasForZodObject(object))); + const fragmentNames = new Set(fragments.map( + object => getNameFromSchemas(getSchemasForZodObject(object)) as string + )); + const fragmentSchemas = Object.fromEntries(fragments.map(fragment => { + const name = getNameFromSchemas(getSchemasForZodObject(fragment)) as string; + fragmentNames.delete(name); + // assumption: schemas cannot have 2 different names + const schema = generateSchema(fragment, { vocabulary: fragmentNames }) as SchemaObject; + fragmentNames.add(name); + + return [name, schema]; + })); + + return [fragmentSchemas, fragmentNames]; +}