diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index 0b375f93f..172c3bedf 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -108,6 +108,42 @@ const specFromRouter = await openAPIGenerator.generate(router, { Features prefixed with `experimental_` are unstable and may lack some functionality. ::: +## Common Schemas + +Define reusable schema components that can be referenced across your OpenAPI specification: + +```ts +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), +}) + +const PetSchema = z.object({ + id: z.string().transform(id => Number(id)).pipe(z.number()), +}) + +const spec = await generator.generate(router, { + commonSchemas: { + User: { + schema: UserSchema, + }, + InputPet: { + strategy: 'input', + schema: PetSchema, + }, + OutputPet: { + strategy: 'output', + schema: PetSchema, + }, + }, +}) +``` + +:::info +The `strategy` option determines which schema definition to use when input and output types differ (defaults to `input`). This is needed because we cannot use the same `$ref` for both input and output in this case. +::: + ## Excluding Procedures You can exclude a procedure from the OpenAPI specification using the `exclude` option: diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index 23d1ba5b9..58415f0de 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -1,7 +1,9 @@ import type { AnyContractProcedure } from '@orpc/contract' import { eventIterator, oc } from '@orpc/contract' import { z } from 'zod' +import * as z4 from 'zod/v4' import { oz, ZodToJsonSchemaConverter } from '../../zod/src' +import { experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverterV4 } from '../../zod/src/zod4' import { customOpenAPIOperation } from './openapi-custom' import { OpenAPIGenerator } from './openapi-generator' @@ -973,4 +975,498 @@ describe('openAPIGenerator', () => { expect(exclude).toHaveBeenNthCalledWith(1, ping, ['ping']) expect(exclude).toHaveBeenNthCalledWith(2, pong, ['pong']) }) + + describe('generator - commonSchemas', async () => { + const generator = new OpenAPIGenerator({ + schemaConverters: [ + new ZodToJsonSchemaConverterV4(), + ], + }) + + const User = z4.object({ + id: z4.string(), + get parent() { + return User.optional() + }, + }) + + const Pet = z4.object({ + id: z4.string().transform(v => Number(v)).pipe(z4.number().min(0).max(100)), + }) + + const Params = z4.object({ + pet: Pet, + }) + + const Query = z4.object({ + user: User, + }) + + const Headers = z4.object({ + user: User, + }) + + const InputDetailedStructure = z4.object({ + params: Params, + query: Query, + headers: Headers, + body: User, + }) + + const OutputDetailedStructure = z4.union([ + z4.object({ + status: z4.literal(200), + headers: Headers, + body: User, + }), + z4.object({ + status: z4.literal(201), + body: User, + }), + ]) + + const spec = await generator.generate({ + user: oc.input(User).errors({ TEST: { data: User } }).output(User), + pet: oc.input(Pet).errors({ TEST: { data: Pet } }).output(Pet), + iterator: oc.input(eventIterator(User, Pet)).output(eventIterator(User, Pet)), + dynamicParams: oc.route({ path: '/user/{id}', method: 'POST' }).input(User), + detailedStructure: oc.route({ path: '/detailed/{pet}', inputStructure: 'detailed', outputStructure: 'detailed' }) + .input(InputDetailedStructure) + .output(OutputDetailedStructure), + }, { + commonSchemas: { + User: { + schema: User, + }, + Pet: { + strategy: 'output', + schema: Pet, + }, + DetailedStructure: { + strategy: 'output', + schema: InputDetailedStructure, + }, + Params: { + strategy: 'output', + schema: Params, + }, + Query: { + schema: Query, + }, + Headers: { + strategy: 'output', + schema: Headers, + }, + OutputDetailedStructure: { + schema: OutputDetailedStructure, + }, + }, + }) + + it('fill correct components.schemas', async () => { + expect(spec.components).toEqual({ + schemas: { + User: { + type: 'object', + properties: { + id: { type: 'string' }, + parent: { $ref: '#/components/schemas/User' }, + }, + required: ['id'], + }, + Pet: { + type: 'object', + properties: { + id: { type: 'number', minimum: 0, maximum: 100 }, + }, + required: ['id'], + }, + Params: { + type: 'object', + properties: { + pet: { $ref: '#/components/schemas/Pet' }, + }, + required: ['pet'], + }, + Query: { + type: 'object', + properties: { + user: { $ref: '#/components/schemas/User' }, + }, + required: ['user'], + }, + Headers: { + type: 'object', + properties: { + user: { $ref: '#/components/schemas/User' }, + }, + required: ['user'], + }, + DetailedStructure: { + type: 'object', + properties: { + params: { $ref: '#/components/schemas/Params' }, + query: { $ref: '#/components/schemas/Query' }, + headers: { $ref: '#/components/schemas/Headers' }, + body: { $ref: '#/components/schemas/User' }, + }, + required: ['params', 'query', 'headers', 'body'], + }, + OutputDetailedStructure: { + anyOf: [ + { + type: 'object', + properties: { + status: { const: 200 }, + headers: { $ref: '#/components/schemas/Headers' }, + body: { $ref: '#/components/schemas/User' }, + }, + required: ['status', 'headers', 'body'], + }, + { + type: 'object', + properties: { + status: { const: 201 }, + body: { $ref: '#/components/schemas/User' }, + }, + required: ['status', 'body'], + }, + ], + }, + }, + }) + }) + + it('works with schema that input & output is same', async () => { + expect(spec.paths!['/user']).toEqual({ + post: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + required: true, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + 500: { + description: '500', + content: { + 'application/json': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + defined: { const: true }, + code: { const: 'TEST' }, + status: { const: 500 }, + message: { type: 'string', default: 'TEST' }, + data: { $ref: '#/components/schemas/User' }, + }, + required: ['defined', 'code', 'status', 'message', 'data'], + }, + { + type: 'object', + properties: { + defined: { const: false }, + code: { type: 'string' }, + status: { type: 'number' }, + message: { type: 'string' }, + data: {}, + }, + required: ['defined', 'code', 'status', 'message'], + }, + ], + }, + }, + }, + }, + }, + operationId: 'user', + }, + }) + }) + + it('works with schema that input & output is different', async () => { + expect(spec.paths!['/pet']).toEqual({ + post: { + operationId: 'pet', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Pet' }, + }, + }, + }, + 500: { + description: '500', + content: { + 'application/json': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + defined: { const: true }, + code: { const: 'TEST' }, + status: { const: 500 }, + message: { type: 'string', default: 'TEST' }, + data: { $ref: '#/components/schemas/Pet' }, + }, + required: ['defined', 'code', 'status', 'message', 'data'], + }, + { + type: 'object', + properties: { + defined: { const: false }, + code: { type: 'string' }, + status: { type: 'number' }, + message: { type: 'string' }, + data: {}, + }, + required: ['defined', 'code', 'status', 'message'], + }, + ], + }, + }, + }, + }, + }, + }, + }) + }) + + it('works with event iterator', async () => { + expect(spec.paths!['/iterator']).toEqual({ + post: { + operationId: 'iterator', + requestBody: { + required: true, + content: { + 'text/event-stream': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + event: { const: 'message' }, + data: { $ref: '#/components/schemas/User' }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'done' }, + data: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'error' }, + data: {}, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event'], + }, + ], + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'text/event-stream': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + event: { const: 'message' }, + data: { $ref: '#/components/schemas/User' }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'done' }, + data: { $ref: '#/components/schemas/Pet' }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'error' }, + data: {}, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event'], + }, + + ], + }, + }, + }, + }, + }, + }, + }) + }) + + it('works with compact + dynamic params', async () => { + expect(spec.paths!['/user/{id}']).toEqual({ + post: { + operationId: 'dynamicParams', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + parent: { $ref: '#/components/schemas/User' }, + }, + required: [], + }, + }, + }, + required: false, + }, + responses: expect.any(Object), + }, + }) + }) + + it('works with complex detailed structure', async () => { + expect(spec.paths!['/detailed/{pet}']).toEqual({ + post: { + operationId: 'detailedStructure', + parameters: [ + { + name: 'pet', + in: 'path', + required: true, + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + }, + { + name: 'user', + in: 'query', + required: true, + schema: { $ref: '#/components/schemas/User' }, + style: 'deepObject', + allowEmptyValue: true, + allowReserved: true, + explode: true, + }, + { + name: 'user', + in: 'header', + required: true, + schema: { $ref: '#/components/schemas/User' }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/User', + }, + }, + }, + required: true, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/User', + }, + }, + }, + headers: { + user: { + required: true, + schema: { + $ref: '#/components/schemas/User', + }, + }, + }, + }, + 201: { + description: 'OK', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/User', + }, + }, + }, + }, + }, + }, + }) + }) + }) }) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index e4cd8500a..3ec40a9f7 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -1,8 +1,8 @@ -import type { AnyContractProcedure, AnyContractRouter, ErrorMap, OpenAPI } from '@orpc/contract' +import type { AnyContractProcedure, AnyContractRouter, AnySchema, ErrorMap, OpenAPI } from '@orpc/contract' import type { StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard' import type { AnyProcedure, AnyRouter } from '@orpc/server' import type { JSONSchema } from './schema' -import type { ConditionalSchemaConverter, SchemaConverter } from './schema-converter' +import type { ConditionalSchemaConverter, SchemaConverter, SchemaConvertOptions } from './schema-converter' import { fallbackORPCErrorMessage, fallbackORPCErrorStatus, isORPCErrorStatus } from '@orpc/client' import { toHttpPath } from '@orpc/client/standard' import { fallbackContractConfig, getEventIteratorSchemaDetails } from '@orpc/contract' @@ -10,7 +10,7 @@ import { getDynamicParams, StandardOpenAPIJsonSerializer } from '@orpc/openapi-c import { resolveContractProcedures } from '@orpc/server' import { clone, stringifyJSON, toArray } from '@orpc/shared' import { applyCustomOpenAPIOperation } from './openapi-custom' -import { checkParamsSchema, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils' +import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils' import { CompositeSchemaConverter } from './schema-converter' import { applySchemaOptionality, expandUnionSchema, isAnySchema, isObjectSchema, separateObjectSchema } from './schema-utils' @@ -27,6 +27,35 @@ export interface OpenAPIGeneratorGenerateOptions extends Partial false */ exclude?: (procedure: AnyProcedure | AnyContractProcedure, path: readonly string[]) => boolean + + /** + * Common schemas to be used for $ref resolution. + */ + commonSchemas?: Record Number(v)) + * .pipe(z.number()) + * + * // Input schema: { type: 'string' } + * // Output schema: { type: 'number' } + * ``` + * + * When schemas differ between input and output, you must explicitly choose + * which version to use for the OpenAPI specification. + * + * @default 'input' - Uses the input schema definition by default + */ + strategy?: SchemaConvertOptions['strategy'] + schema: AnySchema + }> } /** @@ -56,8 +85,11 @@ export class OpenAPIGenerator { info: options.info ?? { title: 'API Reference', version: '0.0.0' }, openapi: '3.1.1', exclude: undefined, + commonSchemas: undefined, } as OpenAPI.Document + const baseSchemaConvertOptions = await this.#resolveCommonSchemas(doc, options.commonSchemas) + const contracts: { contract: AnyContractProcedure, path: readonly string[] }[] = [] await resolveContractProcedures({ path: [], router }, ({ contract, path }) => { @@ -91,9 +123,9 @@ export class OpenAPIGenerator { tags: def.route.tags?.map(tag => tag), } - await this.#request(operationObjectRef, def) - await this.#successResponse(operationObjectRef, def) - await this.#errorResponse(operationObjectRef, def) + await this.#request(doc, operationObjectRef, def, baseSchemaConvertOptions) + await this.#successResponse(doc, operationObjectRef, def, baseSchemaConvertOptions) + await this.#errorResponse(operationObjectRef, def, baseSchemaConvertOptions) } doc.paths ??= {} @@ -120,7 +152,68 @@ export class OpenAPIGenerator { return this.serializer.serialize(doc)[0] as OpenAPI.Document } - async #request(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): Promise { + async #resolveCommonSchemas(doc: OpenAPI.Document, commonSchemas: OpenAPIGeneratorGenerateOptions['commonSchemas']): Promise> { + const baseOptions: Pick = {} + + if (commonSchemas) { + baseOptions.components = [] + + for (const key in commonSchemas) { + const { schema, strategy = 'input' } = commonSchemas[key]! + + const [required, json] = await this.converter.convert(schema, { strategy }) + + const allowedStrategies: SchemaConvertOptions['strategy'][] = [strategy] + + if (strategy === 'input') { + const [outputRequired, outputJson] = await this.converter.convert(schema, { strategy: 'output' }) + + if (outputRequired === required && stringifyJSON(outputJson) === stringifyJSON(json)) { + allowedStrategies.push('output') + } + } + else if (strategy === 'output') { + const [inputRequired, inputJson] = await this.converter.convert(schema, { strategy: 'input' }) + + if (inputRequired === required && stringifyJSON(inputJson) === stringifyJSON(json)) { + allowedStrategies.push('input') + } + } + + baseOptions.components.push({ + schema, + required, + ref: `#/components/schemas/${key}`, + allowedStrategies, + }) + } + + doc.components ??= {} + doc.components.schemas ??= {} + + for (const key in commonSchemas) { + const { schema, strategy = 'input' } = commonSchemas[key]! + const [, json] = await this.converter.convert( + schema, + { + ...baseOptions, + strategy, + minStructureDepthForRef: 1, // not allow use $ref for root schemas + }, + ) + doc.components.schemas[key] = toOpenAPISchema(json) + } + } + + return baseOptions + } + + async #request( + doc: OpenAPI.Document, + ref: OpenAPI.OperationObject, + def: AnyContractProcedure['~orpc'], + baseSchemaConvertOptions: Pick, + ): Promise { const method = fallbackContractConfig('defaultMethod', def.route.method) const details = getEventIteratorSchemaDetails(def.inputSchema) @@ -128,8 +221,8 @@ export class OpenAPIGenerator { ref.requestBody = { required: true, content: toOpenAPIEventIteratorContent( - await this.converter.convert(details.yields, { strategy: 'input' }), - await this.converter.convert(details.returns, { strategy: 'input' }), + await this.converter.convert(details.yields, { ...baseSchemaConvertOptions, strategy: 'input' }), + await this.converter.convert(details.returns, { ...baseSchemaConvertOptions, strategy: 'input' }), ), } @@ -138,7 +231,15 @@ export class OpenAPIGenerator { const dynamicParams = getDynamicParams(def.route.path)?.map(v => v.name) const inputStructure = fallbackContractConfig('defaultInputStructure', def.route.inputStructure) - let [required, schema] = await this.converter.convert(def.inputSchema, { strategy: 'input' }) + + let [required, schema] = await this.converter.convert( + def.inputSchema, + { + ...baseSchemaConvertOptions, + strategy: 'input', + minStructureDepthForRef: dynamicParams?.length || inputStructure === 'detailed' ? 1 : 0, + }, + ) if (isAnySchema(schema) && !dynamicParams?.length) { return @@ -196,11 +297,15 @@ export class OpenAPIGenerator { throw error } + const resolvedParamSchema = schema.properties?.params !== undefined + ? resolveOpenAPIJsonSchemaRef(doc, schema.properties.params) + : undefined + if ( dynamicParams?.length && ( - schema.properties?.params === undefined - || !isObjectSchema(schema.properties.params) - || !checkParamsSchema(schema.properties.params, dynamicParams) + resolvedParamSchema === undefined + || !isObjectSchema(resolvedParamSchema) + || !checkParamsSchema(resolvedParamSchema, dynamicParams) ) ) { throw new OpenAPIGeneratorError( @@ -211,7 +316,9 @@ export class OpenAPIGenerator { for (const from of ['params', 'query', 'headers']) { const fromSchema = schema.properties?.[from] if (fromSchema !== undefined) { - if (!isObjectSchema(fromSchema)) { + const resolvedSchema = resolveOpenAPIJsonSchemaRef(doc, fromSchema) + + if (!isObjectSchema(resolvedSchema)) { throw error } @@ -222,7 +329,7 @@ export class OpenAPIGenerator { : 'query' ref.parameters ??= [] - ref.parameters.push(...toOpenAPIParameters(fromSchema, parameterIn)) + ref.parameters.push(...toOpenAPIParameters(resolvedSchema, parameterIn)) } } @@ -234,7 +341,12 @@ export class OpenAPIGenerator { } } - async #successResponse(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): Promise { + async #successResponse( + doc: OpenAPI.Document, + ref: OpenAPI.OperationObject, + def: AnyContractProcedure['~orpc'], + baseSchemaConvertOptions: Pick, + ): Promise { const outputSchema = def.outputSchema const status = fallbackContractConfig('defaultSuccessStatus', def.route.successStatus) const description = fallbackContractConfig('defaultSuccessDescription', def.route?.successDescription) @@ -246,15 +358,22 @@ export class OpenAPIGenerator { ref.responses[status] = { description, content: toOpenAPIEventIteratorContent( - await this.converter.convert(eventIteratorSchemaDetails.yields, { strategy: 'output' }), - await this.converter.convert(eventIteratorSchemaDetails.returns, { strategy: 'output' }), + await this.converter.convert(eventIteratorSchemaDetails.yields, { ...baseSchemaConvertOptions, strategy: 'output' }), + await this.converter.convert(eventIteratorSchemaDetails.returns, { ...baseSchemaConvertOptions, strategy: 'output' }), ), } return } - const [required, json] = await this.converter.convert(outputSchema, { strategy: 'output' }) + const [required, json] = await this.converter.convert( + outputSchema, + { + ...baseSchemaConvertOptions, + strategy: 'output', + minStructureDepthForRef: outputStructure === 'detailed' ? 1 : 0, + }, + ) if (outputStructure === 'compact') { ref.responses ??= {} @@ -289,17 +408,19 @@ export class OpenAPIGenerator { let schemaDescription: string | undefined if (item.properties?.status !== undefined) { - if (typeof item.properties.status !== 'object' - || item.properties.status.const === undefined - || typeof item.properties.status.const !== 'number' - || !Number.isInteger(item.properties.status.const) - || isORPCErrorStatus(item.properties.status.const) + const statusSchema = resolveOpenAPIJsonSchemaRef(doc, item.properties.status) + + if (typeof statusSchema !== 'object' + || statusSchema.const === undefined + || typeof statusSchema.const !== 'number' + || !Number.isInteger(statusSchema.const) + || isORPCErrorStatus(statusSchema.const) ) { throw error } - schemaStatus = item.properties.status.const - schemaDescription = item.properties.status.description + schemaStatus = statusSchema.const + schemaDescription = statusSchema.description } const itemStatus = schemaStatus ?? status @@ -320,18 +441,20 @@ export class OpenAPIGenerator { } if (item.properties?.headers !== undefined) { - if (!isObjectSchema(item.properties.headers)) { + const headersSchema = resolveOpenAPIJsonSchemaRef(doc, item.properties.headers) + + if (!isObjectSchema(headersSchema)) { throw error } - for (const key in item.properties.headers.properties) { - const headerSchema = item.properties.headers.properties[key] + for (const key in headersSchema.properties) { + const headerSchema = headersSchema.properties[key] if (headerSchema !== undefined) { ref.responses[itemStatus].headers ??= {} ref.responses[itemStatus].headers[key] = { schema: toOpenAPISchema(headerSchema) as any, - required: item.properties.headers.required?.includes(key), + required: headersSchema.required?.includes(key), } } } @@ -345,7 +468,11 @@ export class OpenAPIGenerator { } } - async #errorResponse(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): Promise { + async #errorResponse( + ref: OpenAPI.OperationObject, + def: AnyContractProcedure['~orpc'], + baseSchemaConvertOptions: Pick, + ): Promise { const errorMap = def.errorMap as ErrorMap const errors: Record = {} @@ -360,7 +487,7 @@ export class OpenAPIGenerator { const status = fallbackORPCErrorStatus(code, config.status) const message = fallbackORPCErrorMessage(code, config.message) - const [dataRequired, dataSchema] = await this.converter.convert(config.data, { strategy: 'output' }) + const [dataRequired, dataSchema] = await this.converter.convert(config.data, { ...baseSchemaConvertOptions, strategy: 'output' }) errors[status] ??= [] errors[status].push({ diff --git a/packages/openapi/src/openapi-utils.test.ts b/packages/openapi/src/openapi-utils.test.ts index ed88c2635..a94747d59 100644 --- a/packages/openapi/src/openapi-utils.test.ts +++ b/packages/openapi/src/openapi-utils.test.ts @@ -1,5 +1,6 @@ +import type { OpenAPI } from '@orpc/contract' import type { FileSchema, JSONSchema, ObjectSchema } from './schema' -import { checkParamsSchema, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils' +import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils' it('toOpenAPIPath', () => { expect(toOpenAPIPath('/path')).toBe('/path') @@ -328,3 +329,47 @@ it('toOpenAPISchema', () => { expect(toOpenAPISchema(false)).toEqual({ not: {} }) expect(toOpenAPISchema({ type: 'string' })).toEqual({ type: 'string' }) }) + +describe('resolveOpenAPIJsonSchemaRef', () => { + const doc = { + components: { + schemas: { + 'a': { type: 'string' }, + 'b': { type: 'number' }, + 'c/c': { type: 'object' }, + }, + }, + } as any + + it('works', () => { + expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/a' })).toEqual({ type: 'string' }) + expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/b' })).toEqual({ type: 'number' }) + expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/c/c' })).toEqual({ type: 'object' }) + }) + + it('do nothing if schema is not $ref', () => { + expect(resolveOpenAPIJsonSchemaRef(doc, true)).toEqual(true) + expect(resolveOpenAPIJsonSchemaRef(doc, false)).toEqual(false) + expect(resolveOpenAPIJsonSchemaRef(doc, {})).toEqual({}) + expect(resolveOpenAPIJsonSchemaRef(doc, { type: 'object' })).toEqual({ type: 'object' }) + }) + + it('it do nothing if have no components.schemas', () => { + const doc = {} as OpenAPI.Document + const doc2 = { + components: {}, + } as OpenAPI.Document + + expect(resolveOpenAPIJsonSchemaRef(doc, { type: 'string' })).toEqual({ type: 'string' }) + expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/a' })).toEqual({ $ref: '#/components/schemas/a' }) + expect(resolveOpenAPIJsonSchemaRef(doc2, { $ref: '#/components/schemas/a' })).toEqual({ $ref: '#/components/schemas/a' }) + }) + + it('not resolve if $ref is not a components.schemas', () => { + expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/$defs/a' })).toEqual({ $ref: '#/$defs/a' }) + }) + + it('not resolve if $ref not found', () => { + expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/not-found' })).toEqual({ $ref: '#/components/schemas/not-found' }) + }) +}) diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index 84eacc13d..b3e2b1612 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -165,3 +165,15 @@ export function toOpenAPISchema(schema: JSONSchema): OpenAPI.SchemaObject & obje ? { not: {} } : schema as OpenAPI.SchemaObject } + +const OPENAPI_JSON_SCHEMA_REF_PREFIX = /* @__PURE__ */ '#/components/schemas/' + +export function resolveOpenAPIJsonSchemaRef(doc: OpenAPI.Document, schema: JSONSchema): JSONSchema { + if (typeof schema !== 'object' || !schema.$ref?.startsWith(OPENAPI_JSON_SCHEMA_REF_PREFIX)) { + return schema + } + + const name = schema.$ref.slice(OPENAPI_JSON_SCHEMA_REF_PREFIX.length) + const resolved = doc.components?.schemas?.[name] + return resolved as JSONSchema ?? schema +} diff --git a/packages/openapi/src/schema-converter.ts b/packages/openapi/src/schema-converter.ts index c7cc233f5..e07aaf210 100644 --- a/packages/openapi/src/schema-converter.ts +++ b/packages/openapi/src/schema-converter.ts @@ -2,8 +2,29 @@ import type { AnySchema } from '@orpc/contract' import type { Promisable } from '@orpc/shared' import type { JSONSchema } from './schema' +export interface SchemaConverterComponent { + allowedStrategies: SchemaConvertOptions['strategy'][] + schema: AnySchema + required: boolean + ref: string +} + export interface SchemaConvertOptions { strategy: 'input' | 'output' + + /** + * Common components should use `$ref` to represent themselves if matched. + */ + components?: SchemaConverterComponent[] + + /** + * Minimum schema structure depth required before using `$ref` for components. + * + * For example, if set to 2, `$ref` will only be used for schemas nested at depth 2 or greater. + * + * @default 0 - No depth limit; + */ + minStructureDepthForRef?: number } export interface SchemaConverter { diff --git a/packages/zod/src/converter.components.test.ts b/packages/zod/src/converter.components.test.ts new file mode 100644 index 000000000..ca434ca5c --- /dev/null +++ b/packages/zod/src/converter.components.test.ts @@ -0,0 +1,289 @@ +import { z } from 'zod' +import { ZodToJsonSchemaConverter } from './converter' + +const User = z.object({ + id: z.string(), + name: z.string(), + age: z.number().optional(), + get parents(): any { + return z.array(User).optional() + }, +}) + +const Pet = z.object({ + id: z.string(), + name: z.string(), + owner: z.lazy(() => User), +}) + +describe('zodToJsonSchemaConverter - components', () => { + const converter = new ZodToJsonSchemaConverter({ maxLazyDepth: 1 }) + + it.each([true, false])('works with Pet schema (required=%s)', (componentRequired) => { + const [required, jsonSchema] = converter.convert( + Pet, + { + strategy: 'input', + components: [ + { + schema: User, + required: componentRequired, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + owner: { $ref: '#/components/schemas/User' }, + }, + required: componentRequired ? ['id', 'name', 'owner'] : ['id', 'name'], + }) + }) + + describe('minStructureDepthForRef', () => { + it.each([true, false])('works with User schema (minStructureDepthForRef=0, required=%s)', (componentRequired) => { + const [required, jsonSchema] = converter.convert( + User, + { + strategy: 'input', + components: [ + { + schema: User, + required: componentRequired, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 0, + }, + ) + + expect(required).toBe(componentRequired) + expect(jsonSchema).toEqual({ $ref: '#/components/schemas/User' }) + }) + + it.each([true, false])('works with User schema (minStructureDepthForRef=1, strategy=input, required=%s)', (componentRequired) => { + const [required, jsonSchema] = converter.convert( + User, + { + strategy: 'input', + components: [ + { + schema: User, + required: componentRequired, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 1, + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + age: { type: 'number' }, + parents: { type: 'array', items: componentRequired + ? { $ref: '#/components/schemas/User' } + : { + anyOf: [ + { + $ref: '#/components/schemas/User', + }, + { + not: {}, + }, + ], + } }, + }, + required: ['id', 'name'], + }) + }) + + it.each([true, false])('works with User schema (minStructureDepthForRef=1, strategy=output, required=%s)', (componentRequired) => { + const [required, jsonSchema] = converter.convert( + User, + { + strategy: 'output', + components: [ + { + schema: User, + required: componentRequired, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 1, + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + age: { type: 'number' }, + parents: { + type: 'array', + items: componentRequired + ? { $ref: '#/components/schemas/User' } + : { + anyOf: [ + { + $ref: '#/components/schemas/User', + }, + { + type: 'null', + }, + ], + }, + }, + }, + required: ['id', 'name'], + }) + }) + + it('works with User schema (minStructureDepthForRef=3)', () => { + const [required, jsonSchema] = converter.convert( + User, + { + strategy: 'input', + components: [ + { + schema: User, + required: true, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 3, + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + age: { type: 'number' }, + parents: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + age: { type: 'number' }, + parents: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + }, + required: ['id', 'name'], + }, + }, + }, + required: ['id', 'name'], + }) + }) + }) + + it('on unsupported strategy', () => { + const Pet = z.object({ + id: z.string(), + name: z.string(), + }) + + const [required, jsonSchema] = converter.convert( + Pet, + { + strategy: 'output', + components: [ + { + schema: Pet, + required: true, + ref: '#/components/schemas/Pet', + allowedStrategies: ['input'], + }, + ], + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['id', 'name'], + }) + }) + + it('complex case', () => { + const [required, jsonSchema] = converter.convert( + z.object({ + users: z.array(User).optional(), + pets: z.array(Pet).optional(), + nested: z.array(z.object({ + user: User.optional(), + })), + }).optional(), + { + strategy: 'input', + components: [ + { + schema: User, + required: true, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + { + schema: Pet, + required: true, + ref: '#/components/schemas/Pet', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 2, + }, + ) + + expect(required).toBe(false) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + users: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + pets: { + type: 'array', + items: { $ref: '#/components/schemas/Pet' }, + }, + nested: { + type: 'array', + items: { + type: 'object', + properties: { + user: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + required: ['nested'], + }) + }) +}) diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index 51a414266..723e79e20 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -624,3 +624,32 @@ it('zodToJsonSchemaConverter.condition', async () => { expect(converter.condition(v.string())).toBe(false) }) + +it('works with recursive schemas', () => { + function createSchema(depth: number): ZodTypeAny { + if (depth <= 0) { + return z.string() + } + return z.object({ + id: z.string(), + name: z.string(), + children: z.array(createSchema(depth - 1)).optional(), + }) + } + const converter = new ZodToJsonSchemaConverter() + + const [required, json] = converter.convert(createSchema(100), { strategy: 'input' }) + expect(required).toBe(true) + expect(json).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + children: { + type: 'array', + items: expect.objectContaining({ type: 'object' }), + }, + }, + required: ['id', 'name'], + }) +}) diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index f63d99d43..cae2dad73 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -33,6 +33,7 @@ import type { ZodUnionOptions, } from 'zod' import { JSONSchemaFormat } from '@orpc/openapi' +import { toArray } from '@orpc/shared' import escapeStringRegexp from 'escape-string-regexp' import { ZodFirstPartyTypeKind } from 'zod' import { getCustomJsonSchema } from './custom-json-schema' @@ -40,20 +41,22 @@ import { getCustomZodDef } from './schemas/base' export interface ZodToJsonSchemaOptions { /** - * Max depth of lazy type, if it exceeds. + * Max depth of lazy type * - * Used `{}` when reach max depth + * Used `{}` when exceed max depth * * @default 3 */ maxLazyDepth?: number /** - * The schema to be used when the Zod schema is unsupported. + * Max depth of nested types * - * @default { not: {} } + * Used anyJsonSchema (`{}`) when exceed max depth + * + * @default 10 */ - unsupportedJsonSchema?: Exclude + maxStructureDepth?: number /** * The schema to be used to represent the any | unknown type. @@ -61,15 +64,24 @@ export interface ZodToJsonSchemaOptions { * @default { } */ anyJsonSchema?: Exclude + + /** + * The schema to be used when the Zod schema is unsupported. + * + * @default { not: {} } + */ + unsupportedJsonSchema?: Exclude } export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { private readonly maxLazyDepth: Exclude + private readonly maxStructureDepth: Exclude private readonly unsupportedJsonSchema: Exclude private readonly anyJsonSchema: Exclude constructor(options: ZodToJsonSchemaOptions = {}) { this.maxLazyDepth = options.maxLazyDepth ?? 3 + this.maxStructureDepth = options.maxStructureDepth ?? 10 this.unsupportedJsonSchema = options.unsupportedJsonSchema ?? { not: {} } this.anyJsonSchema = options.anyJsonSchema ?? {} } @@ -84,9 +96,24 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { lazyDepth = 0, isHandledCustomJSONSchema = false, isHandledZodDescription = false, + structureDepth = 0, ): [required: boolean, jsonSchema: Exclude] { const def = (schema as ZodTypeAny)._def + if (structureDepth > this.maxStructureDepth) { + return [false, this.anyJsonSchema] + } + + if (!options.minStructureDepthForRef || options.minStructureDepthForRef <= structureDepth) { + const components = toArray(options.components) + + for (const component of components) { + if (component.schema === schema && component.allowedStrategies.includes(options.strategy)) { + return [component.required, { $ref: component.ref }] + } + } + } + if (!isHandledZodDescription && 'description' in def && typeof def.description === 'string') { const [required, json] = this.convert( schema, @@ -94,6 +121,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { lazyDepth, isHandledCustomJSONSchema, true, + structureDepth, ) return [required, { ...json, description: def.description }] @@ -109,6 +137,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { lazyDepth, true, isHandledZodDescription, + structureDepth, ) return [required, { ...json, ...customJSONSchema }] @@ -320,7 +349,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { const json: JSONSchema = { type: 'array' } - const [itemRequired, itemJson] = this.convert(def.type, options, lazyDepth, false, false) + const [itemRequired, itemJson] = this.convert(def.type, options, lazyDepth, false, false, structureDepth + 1) json.items = this.#toArrayItemJsonSchema(itemRequired, itemJson, options.strategy) @@ -347,7 +376,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { const json: JSONSchema = { type: 'array' } for (const item of schema_._def.items) { - const [itemRequired, itemJson] = this.convert(item, options, lazyDepth, false, false) + const [itemRequired, itemJson] = this.convert(item, options, lazyDepth, false, false, structureDepth + 1) prefixItems.push( this.#toArrayItemJsonSchema(itemRequired, itemJson, options.strategy), @@ -359,7 +388,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { } if (schema_._def.rest) { - const [itemRequired, itemJson] = this.convert(schema_._def.rest, options, lazyDepth, false, false) + const [itemRequired, itemJson] = this.convert(schema_._def.rest, options, lazyDepth, false, false, structureDepth + 1) json.items = this.#toArrayItemJsonSchema(itemRequired, itemJson, options.strategy) } @@ -375,7 +404,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { const required: string[] = [] for (const [key, value] of Object.entries(schema_.shape)) { - const [itemRequired, itemJson] = this.convert(value, options, lazyDepth, false, false) + const [itemRequired, itemJson] = this.convert(value, options, lazyDepth, false, false, structureDepth + 1) properties[key] = itemJson @@ -400,7 +429,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { } } else { - const [_, addJson] = this.convert(schema_._def.catchall, options, lazyDepth, false, false) + const [_, addJson] = this.convert(schema_._def.catchall, options, lazyDepth, false, false, structureDepth + 1) json.additionalProperties = addJson } @@ -413,13 +442,13 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { const json: JSONSchema = { type: 'object' } - const [__, keyJson] = this.convert(schema_._def.keyType, options, lazyDepth, false, false) + const [__, keyJson] = this.convert(schema_._def.keyType, options, lazyDepth, false, false, structureDepth + 1) if (Object.entries(keyJson).some(([k, v]) => k !== 'type' || v !== 'string')) { json.propertyNames = keyJson } - const [_, itemJson] = this.convert(schema_._def.valueType, options, lazyDepth, false, false) + const [_, itemJson] = this.convert(schema_._def.valueType, options, lazyDepth, false, false, structureDepth + 1) json.additionalProperties = itemJson @@ -431,7 +460,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { const json: JSONSchema = { type: 'array', uniqueItems: true } - const [itemRequired, itemJson] = this.convert(schema_._def.valueType, options, lazyDepth, false, false) + const [itemRequired, itemJson] = this.convert(schema_._def.valueType, options, lazyDepth, false, false, structureDepth + 1) json.items = this.#toArrayItemJsonSchema(itemRequired, itemJson, options.strategy) @@ -441,8 +470,8 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case ZodFirstPartyTypeKind.ZodMap: { const schema_ = schema as ZodMap - const [keyRequired, keyJson] = this.convert(schema_._def.keyType, options, lazyDepth, false, false) - const [valueRequired, valueJson] = this.convert(schema_._def.valueType, options, lazyDepth, false, false) + const [keyRequired, keyJson] = this.convert(schema_._def.keyType, options, lazyDepth, false, false, structureDepth + 1) + const [valueRequired, valueJson] = this.convert(schema_._def.valueType, options, lazyDepth, false, false, structureDepth + 1) return [true, { type: 'array', @@ -468,7 +497,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { let required = true for (const item of schema_._def.options) { - const [itemRequired, itemJson] = this.convert(item, options, lazyDepth, false, false) + const [itemRequired, itemJson] = this.convert(item, options, lazyDepth, false, false, structureDepth) if (!itemRequired) { required = false @@ -496,7 +525,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { let required: boolean = false for (const item of [schema_._def.left, schema_._def.right]) { - const [itemRequired, itemJson] = this.convert(item, options, lazyDepth, false, false) + const [itemRequired, itemJson] = this.convert(item, options, lazyDepth, false, false, structureDepth) allOf.push(itemJson) @@ -509,26 +538,28 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { } case ZodFirstPartyTypeKind.ZodLazy: { - if (lazyDepth >= this.maxLazyDepth) { + const currentLazyDepth = lazyDepth + 1 + + if (currentLazyDepth > this.maxLazyDepth) { return [false, this.anyJsonSchema] } const schema_ = schema as ZodLazy - return this.convert(schema_._def.getter(), options, lazyDepth + 1, false, false) + return this.convert(schema_._def.getter(), options, currentLazyDepth, false, false, structureDepth) } case ZodFirstPartyTypeKind.ZodOptional: { const schema_ = schema as ZodOptional - const [_, inner] = this.convert(schema_._def.innerType, options, lazyDepth, false, false) + const [_, inner] = this.convert(schema_._def.innerType, options, lazyDepth, false, false, structureDepth) return [false, inner] } case ZodFirstPartyTypeKind.ZodReadonly: { const schema_ = schema as ZodReadonly - const [required, json] = this.convert(schema_._def.innerType, options, lazyDepth, false, false) + const [required, json] = this.convert(schema_._def.innerType, options, lazyDepth, false, false, structureDepth) return [required, { ...json, readOnly: true }] } @@ -536,7 +567,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case ZodFirstPartyTypeKind.ZodDefault: { const schema_ = schema as ZodDefault - const [_, json] = this.convert(schema_._def.innerType, options, lazyDepth, false, false) + const [_, json] = this.convert(schema_._def.innerType, options, lazyDepth, false, false, structureDepth) return [false, { default: schema_._def.defaultValue(), ...json }] } @@ -548,17 +579,17 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { return [false, this.anyJsonSchema] } - return this.convert(schema_._def.schema, options, lazyDepth, false, false) + return this.convert(schema_._def.schema, options, lazyDepth, false, false, structureDepth) } case ZodFirstPartyTypeKind.ZodCatch: { const schema_ = schema as ZodCatch - return this.convert(schema_._def.innerType, options, lazyDepth, false, false) + return this.convert(schema_._def.innerType, options, lazyDepth, false, false, structureDepth) } case ZodFirstPartyTypeKind.ZodBranded: { const schema_ = schema as ZodBranded - return this.convert(schema_._def.type, options, lazyDepth, false, false) + return this.convert(schema_._def.type, options, lazyDepth, false, false, structureDepth) } case ZodFirstPartyTypeKind.ZodPipeline: { @@ -570,13 +601,14 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { lazyDepth, false, false, + structureDepth, ) } case ZodFirstPartyTypeKind.ZodNullable: { const schema_ = schema as ZodNullable - const [required, json] = this.convert(schema_._def.innerType, options, lazyDepth, false, false) + const [required, json] = this.convert(schema_._def.innerType, options, lazyDepth, false, false, structureDepth) return [required, { anyOf: [{ type: 'null' }, json] }] } diff --git a/packages/zod/src/zod4/converter.components.test.ts b/packages/zod/src/zod4/converter.components.test.ts new file mode 100644 index 000000000..74bf14aab --- /dev/null +++ b/packages/zod/src/zod4/converter.components.test.ts @@ -0,0 +1,278 @@ +import { z } from 'zod/v4' +import { experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter } from './converter' + +const User = z.object({ + id: z.string(), + name: z.string(), + age: z.number().optional(), + get parents() { + return z.array(User).optional() + }, +}) + +const Pet = z.object({ + id: z.string(), + name: z.string(), + owner: z.lazy(() => User), +}) + +describe('zodToJsonSchemaConverter - components', () => { + const converter = new ZodToJsonSchemaConverter({ maxLazyDepth: 1 }) + + it.each([true, false])('works with Pet schema (required=%s)', (componentRequired) => { + const [required, jsonSchema] = converter.convert( + Pet, + { + strategy: 'input', + components: [ + { + schema: User, + required: componentRequired, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + owner: { $ref: '#/components/schemas/User' }, + }, + required: componentRequired ? ['id', 'name', 'owner'] : ['id', 'name'], + }) + }) + + describe('minStructureDepthForRef', () => { + it.each([true, false])('works with User schema (minStructureDepthForRef=0, required=%s)', (componentRequired) => { + const [required, jsonSchema] = converter.convert( + User, + { + strategy: 'input', + components: [ + { + schema: User, + required: componentRequired, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 0, + }, + ) + + expect(required).toBe(componentRequired) + expect(jsonSchema).toEqual({ $ref: '#/components/schemas/User' }) + }) + + it.each([true, false])('works with User schema (minStructureDepthForRef=1, strategy=input, required=%s)', (componentRequired) => { + const [required, jsonSchema] = converter.convert( + User, + { + strategy: 'input', + components: [ + { + schema: User, + required: componentRequired, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 1, + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + age: { type: 'number' }, + parents: { type: 'array', items: { $ref: '#/components/schemas/User' } }, + }, + required: ['id', 'name'], + }) + }) + + it.each([true, false])('works with User schema (minStructureDepthForRef=1, strategy=output, required=%s)', (componentRequired) => { + const [required, jsonSchema] = converter.convert( + User, + { + strategy: 'output', + components: [ + { + schema: User, + required: componentRequired, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 1, + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + age: { type: 'number' }, + parents: { + type: 'array', + items: componentRequired + ? { $ref: '#/components/schemas/User' } + : { + anyOf: [ + { + $ref: '#/components/schemas/User', + }, + { + type: 'null', + }, + ], + }, + }, + }, + required: ['id', 'name'], + }) + }) + + it('works with User schema (minStructureDepthForRef=3)', () => { + const [required, jsonSchema] = converter.convert( + User, + { + strategy: 'input', + components: [ + { + schema: User, + required: true, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 3, + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + age: { type: 'number' }, + parents: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + age: { type: 'number' }, + parents: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + }, + required: ['id', 'name'], + }, + }, + }, + required: ['id', 'name'], + }) + }) + }) + + it('on unsupported strategy', () => { + const Pet = z.object({ + id: z.string(), + name: z.string(), + }) + + const [required, jsonSchema] = converter.convert( + Pet, + { + strategy: 'output', + components: [ + { + schema: Pet, + required: true, + ref: '#/components/schemas/Pet', + allowedStrategies: ['input'], + }, + ], + }, + ) + + expect(required).toBe(true) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['id', 'name'], + }) + }) + + it('complex case', () => { + const [required, jsonSchema] = converter.convert( + z.object({ + users: z.array(User).optional(), + pets: z.array(Pet).optional(), + nested: z.array(z.object({ + user: User.optional(), + })), + }).optional(), + { + strategy: 'input', + components: [ + { + schema: User, + required: true, + ref: '#/components/schemas/User', + allowedStrategies: ['input', 'output'], + }, + { + schema: Pet, + required: true, + ref: '#/components/schemas/Pet', + allowedStrategies: ['input', 'output'], + }, + ], + minStructureDepthForRef: 2, + }, + ) + + expect(required).toBe(false) + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + users: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + pets: { + type: 'array', + items: { $ref: '#/components/schemas/Pet' }, + }, + nested: { + type: 'array', + items: { + type: 'object', + properties: { + user: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + required: ['nested'], + }) + }) +}) diff --git a/packages/zod/src/zod4/converter.test.ts b/packages/zod/src/zod4/converter.test.ts index fcb59fea9..28e160b07 100644 --- a/packages/zod/src/zod4/converter.test.ts +++ b/packages/zod/src/zod4/converter.test.ts @@ -7,6 +7,31 @@ import { describe('zodToJsonSchemaConverter', () => { const converter = new ZodToJsonSchemaConverter() + it('works with recursive schemas', () => { + const Schema = z.object({ + id: z.string(), + name: z.string(), + get parents() { + return z.array(Schema).optional() + }, + }) + + const [required, json] = converter.convert(Schema, { strategy: 'input' }) + expect(required).toBe(true) + expect(json).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + parents: { + type: 'array', + items: expect.objectContaining({ type: 'object' }), + }, + }, + required: ['id', 'name'], + }) + }) + it('.condition', async () => { expect(converter.condition(z.string())).toBe(true) expect(converter.condition(z.string().optional())).toBe(true) diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts index e92c714ba..a0bdbce56 100644 --- a/packages/zod/src/zod4/converter.ts +++ b/packages/zod/src/zod4/converter.ts @@ -28,7 +28,7 @@ import type { $ZodUnion, } from 'zod/v4/core' import { JSONSchemaContentEncoding, JSONSchemaFormat } from '@orpc/openapi' -import { intercept } from '@orpc/shared' +import { intercept, toArray } from '@orpc/shared' import { globalRegistry, } from 'zod/v4/core' @@ -40,14 +40,23 @@ import { export interface experimental_ZodToJsonSchemaOptions { /** - * Max depth of lazy type, if it exceeds. + * Max depth of lazy type. * - * Used anyJsonSchema (`{}`) when reach max depth + * Used anyJsonSchema (`{}`) when exceed max depth * * @default 2 */ maxLazyDepth?: number + /** + * Max depth of nested types. + * + * Used anyJsonSchema (`{}`) when exceed max depth + * + * @default 10 + */ + maxStructureDepth?: number + /** * The schema to be used to represent the any | unknown type. * @@ -77,6 +86,7 @@ export interface experimental_ZodToJsonSchemaOptions { export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaConverter { private readonly maxLazyDepth: Exclude + private readonly maxStructureDepth: Exclude private readonly anyJsonSchema: Exclude private readonly unsupportedJsonSchema: Exclude private readonly undefinedJsonSchema: Exclude @@ -84,6 +94,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC constructor(options: experimental_ZodToJsonSchemaOptions = {}) { this.maxLazyDepth = options.maxLazyDepth ?? 2 + this.maxStructureDepth = options.maxStructureDepth ?? 10 this.anyJsonSchema = options.anyJsonSchema ?? {} this.unsupportedJsonSchema = options.unsupportedJsonSchema ?? { not: {} } this.undefinedJsonSchema = options.undefinedJsonSchema ?? { not: {} } @@ -94,25 +105,43 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC return schema !== undefined && schema['~standard'].vendor === 'zod' } - convert(schema: AnySchema | undefined, options: SchemaConvertOptions): [required: boolean, jsonSchema: Exclude] { - return this.#convert(schema as $ZodType, options, 0) + convert( + schema: AnySchema | undefined, + options: SchemaConvertOptions, + ): [required: boolean, jsonSchema: Exclude] { + return this.#convert(schema as $ZodType, options, 0, 0) } #convert( schema: $ZodType, options: SchemaConvertOptions, lazyDepth: number, + structureDepth: number, isHandledCustomJSONSchema: boolean = false, ): [required: boolean, jsonSchema: Exclude] { return intercept( this.interceptors, { schema, options, lazyDepth, isHandledCustomJSONSchema }, ({ schema, options, lazyDepth, isHandledCustomJSONSchema }) => { + if (structureDepth > this.maxStructureDepth) { + return [false, this.anyJsonSchema] + } + + if (!options.minStructureDepthForRef || options.minStructureDepthForRef <= structureDepth) { + const components = toArray(options.components) + + for (const component of components) { + if (component.schema === schema && component.allowedStrategies.includes(options.strategy)) { + return [component.required, { $ref: component.ref }] + } + } + } + if (!isHandledCustomJSONSchema) { const customJSONSchema = this.#getCustomJsonSchema(schema, options) if (customJSONSchema) { - const [required, json] = this.#convert(schema, options, lazyDepth, true) + const [required, json] = this.#convert(schema, options, lazyDepth, structureDepth, true) return [required, { ...json, ...customJSONSchema }] } @@ -246,7 +275,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC json.maxItems = maximum } - json.items = this.#handleArrayItemJsonSchema(this.#convert(array._zod.def.element, options, lazyDepth), options) + json.items = this.#handleArrayItemJsonSchema(this.#convert(array._zod.def.element, options, lazyDepth, structureDepth + 1), options) return [true, json] } @@ -256,7 +285,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC const json: JSONSchema & { required?: string[] } = { type: 'object' } for (const [key, value] of Object.entries(object._zod.def.shape)) { - const [itemRequired, itemJson] = this.#convert(value, options, lazyDepth) + const [itemRequired, itemJson] = this.#convert(value, options, lazyDepth, structureDepth + 1) json.properties ??= {} json.properties[key] = itemJson @@ -272,7 +301,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC json.additionalProperties = false } else { - const [_, addJson] = this.#convert(object._zod.def.catchall, options, lazyDepth) + const [_, addJson] = this.#convert(object._zod.def.catchall, options, lazyDepth, structureDepth + 1) json.additionalProperties = addJson } } @@ -287,7 +316,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC let required = true for (const item of union._zod.def.options) { - const [itemRequired, itemJson] = this.#convert(item, options, lazyDepth) + const [itemRequired, itemJson] = this.#convert(item, options, lazyDepth, structureDepth) if (!itemRequired) { required = false @@ -315,7 +344,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC let required = false for (const item of [intersection._zod.def.left, intersection._zod.def.right]) { - const [itemRequired, itemJson] = this.#convert(item, options, lazyDepth) + const [itemRequired, itemJson] = this.#convert(item, options, lazyDepth, structureDepth) json.allOf.push(itemJson) @@ -332,11 +361,11 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC const json: JSONSchema & { prefixItems: JSONSchema[] } = { type: 'array', prefixItems: [] } for (const item of tuple._zod.def.items) { - json.prefixItems.push(this.#handleArrayItemJsonSchema(this.#convert(item, options, lazyDepth), options)) + json.prefixItems.push(this.#handleArrayItemJsonSchema(this.#convert(item, options, lazyDepth, structureDepth + 1), options)) } if (tuple._zod.def.rest) { - json.items = this.#handleArrayItemJsonSchema(this.#convert(tuple._zod.def.rest, options, lazyDepth), options) + json.items = this.#handleArrayItemJsonSchema(this.#convert(tuple._zod.def.rest, options, lazyDepth, structureDepth + 1), options) } const { minimum, maximum } = tuple._zod.bag @@ -356,8 +385,8 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC const record = schema as $ZodRecord const json: JSONSchema = { type: 'object' } - json.propertyNames = (this.#convert(record._zod.def.keyType, options, lazyDepth))[1] - json.additionalProperties = (this.#convert(record._zod.def.valueType, options, lazyDepth))[1] + json.propertyNames = (this.#convert(record._zod.def.keyType, options, lazyDepth, structureDepth + 1))[1] + json.additionalProperties = (this.#convert(record._zod.def.valueType, options, lazyDepth, structureDepth + 1))[1] return [true, json] } @@ -370,8 +399,8 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC items: { type: 'array', prefixItems: [ - this.#handleArrayItemJsonSchema(this.#convert(map._zod.def.keyType, options, lazyDepth), options), - this.#handleArrayItemJsonSchema(this.#convert(map._zod.def.valueType, options, lazyDepth), options), + this.#handleArrayItemJsonSchema(this.#convert(map._zod.def.keyType, options, lazyDepth, structureDepth + 1), options), + this.#handleArrayItemJsonSchema(this.#convert(map._zod.def.valueType, options, lazyDepth, structureDepth + 1), options), ], maxItems: 2, minItems: 2, @@ -384,7 +413,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC return [true, { type: 'array', uniqueItems: true, - items: this.#handleArrayItemJsonSchema(this.#convert(set._zod.def.valueType, options, lazyDepth), options), + items: this.#handleArrayItemJsonSchema(this.#convert(set._zod.def.valueType, options, lazyDepth, structureDepth + 1), options), }] } @@ -442,14 +471,14 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC case 'nullable': { const nullable = schema as $ZodNullable - const [required, json] = this.#convert(nullable._zod.def.innerType, options, lazyDepth) + const [required, json] = this.#convert(nullable._zod.def.innerType, options, lazyDepth, structureDepth) return [required, { anyOf: [json, { type: 'null' }] }] } case 'nonoptional': { const nonoptional = schema as $ZodNonOptional - const [, json] = this.#convert(nonoptional._zod.def.innerType, options, lazyDepth) + const [, json] = this.#convert(nonoptional._zod.def.innerType, options, lazyDepth, structureDepth) return [true, json] } @@ -460,7 +489,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC case 'default': case 'prefault': { const default_ = schema as $ZodDefault | $ZodPrefault - const [, json] = this.#convert(default_._zod.def.innerType, options, lazyDepth) + const [, json] = this.#convert(default_._zod.def.innerType, options, lazyDepth, structureDepth) return [false, { ...json, @@ -470,7 +499,7 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC case 'catch': { const catch_ = schema as $ZodCatch - return this.#convert(catch_._zod.def.innerType, options, lazyDepth) + return this.#convert(catch_._zod.def.innerType, options, lazyDepth, structureDepth) } case 'nan': { @@ -479,12 +508,12 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC case 'pipe': { const pipe = schema as $ZodPipe - return this.#convert(options.strategy === 'input' ? pipe._zod.def.in : pipe._zod.def.out, options, lazyDepth) + return this.#convert(options.strategy === 'input' ? pipe._zod.def.in : pipe._zod.def.out, options, lazyDepth, structureDepth) } case 'readonly': { const readonly_ = schema as $ZodReadonly - const [required, json] = this.#convert(readonly_._zod.def.innerType, options, lazyDepth) + const [required, json] = this.#convert(readonly_._zod.def.innerType, options, lazyDepth, structureDepth) return [required, { ...json, readOnly: true }] } @@ -499,22 +528,24 @@ export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaC case 'optional': { const optional = schema as $ZodOptional - const [, json] = this.#convert(optional._zod.def.innerType, options, lazyDepth) + const [, json] = this.#convert(optional._zod.def.innerType, options, lazyDepth, structureDepth) return [false, json] } case 'lazy': { const lazy = schema as $ZodLazy - if (lazyDepth >= this.maxLazyDepth) { + const currentLazyDepth = lazyDepth + 1 + + if (currentLazyDepth > this.maxLazyDepth) { return [false, this.anyJsonSchema] } - return this.#convert(lazy._zod.def.getter(), options, lazyDepth + 1) + return this.#convert(lazy._zod.def.getter(), options, currentLazyDepth, structureDepth) } default: { - const _unsupported: 'interface' | 'int' | 'symbol' | 'promise' | 'custom' = schema._zod.def.type + const _unsupported: 'int' | 'symbol' | 'promise' | 'custom' = schema._zod.def.type return [true, this.unsupportedJsonSchema] } } diff --git a/playgrounds/astro/src/pages/api/[...rest].ts b/playgrounds/astro/src/pages/api/[...rest].ts index 07ce177d5..75740bddb 100644 --- a/playgrounds/astro/src/pages/api/[...rest].ts +++ b/playgrounds/astro/src/pages/api/[...rest].ts @@ -5,6 +5,9 @@ import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import { router } from '../../router' import type { APIRoute } from 'astro' +import { NewUserSchema, UserSchema } from '../../schemas/user' +import { CredentialSchema, TokenSchema } from '../../schemas/auth' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from '../../schemas/planet' const handler = new OpenAPIHandler(router, { interceptors: [ @@ -23,6 +26,15 @@ const handler = new OpenAPIHandler(router, { title: 'ORPC Playground', version: '1.0.0', }, + commonSchemas: { + NewUser: { schema: NewUserSchema }, + User: { schema: UserSchema }, + Credential: { schema: CredentialSchema }, + Token: { schema: TokenSchema }, + NewPlanet: { schema: NewPlanetSchema }, + UpdatePlanet: { schema: UpdatePlanetSchema }, + Planet: { schema: PlanetSchema }, + }, security: [{ bearerAuth: [] }], components: { securitySchemes: { diff --git a/playgrounds/contract-first/src/main.ts b/playgrounds/contract-first/src/main.ts index d4d00b488..6f47f63d8 100644 --- a/playgrounds/contract-first/src/main.ts +++ b/playgrounds/contract-first/src/main.ts @@ -6,6 +6,9 @@ import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from './router' import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import './polyfill' +import { NewUserSchema, UserSchema } from './schemas/user' +import { CredentialSchema, TokenSchema } from './schemas/auth' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from './schemas/planet' const openAPIHandler = new OpenAPIHandler(router, { interceptors: [ @@ -24,6 +27,15 @@ const openAPIHandler = new OpenAPIHandler(router, { title: 'ORPC Playground', version: '1.0.0', }, + commonSchemas: { + NewUser: { schema: NewUserSchema }, + User: { schema: UserSchema }, + Credential: { schema: CredentialSchema }, + Token: { schema: TokenSchema }, + NewPlanet: { schema: NewPlanetSchema }, + UpdatePlanet: { schema: UpdatePlanetSchema }, + Planet: { schema: PlanetSchema }, + }, security: [{ bearerAuth: [] }], components: { securitySchemes: { diff --git a/playgrounds/nest/src/reference/reference.service.ts b/playgrounds/nest/src/reference/reference.service.ts index 4d04fcfff..8ac9b6ad3 100644 --- a/playgrounds/nest/src/reference/reference.service.ts +++ b/playgrounds/nest/src/reference/reference.service.ts @@ -1,13 +1,15 @@ import { OpenAPIGenerator } from '@orpc/openapi' import { ZodToJsonSchemaConverter } from '@orpc/zod' import { contract } from 'src/contract' +import { CredentialSchema, TokenSchema } from 'src/schemas/auth' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from 'src/schemas/planet' +import { NewUserSchema, UserSchema } from 'src/schemas/user' export class ReferenceService { private readonly openapiGenerator = new OpenAPIGenerator({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], - }) spec() { @@ -25,6 +27,15 @@ export class ReferenceService { }, }, }, + commonSchemas: { + NewUser: { schema: NewUserSchema }, + User: { schema: UserSchema }, + Credential: { schema: CredentialSchema }, + Token: { schema: TokenSchema }, + NewPlanet: { schema: NewPlanetSchema }, + UpdatePlanet: { schema: UpdatePlanetSchema }, + Planet: { schema: PlanetSchema }, + }, servers: [ { url: 'http://localhost:3000' }, ], diff --git a/playgrounds/next/src/app/api/[[...rest]]/route.ts b/playgrounds/next/src/app/api/[[...rest]]/route.ts index b5422ab32..0e680aa2e 100644 --- a/playgrounds/next/src/app/api/[[...rest]]/route.ts +++ b/playgrounds/next/src/app/api/[[...rest]]/route.ts @@ -4,6 +4,9 @@ import { onError } from '@orpc/server' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import '../../../polyfill' +import { NewUserSchema, UserSchema } from '@/schemas/user' +import { CredentialSchema, TokenSchema } from '@/schemas/auth' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from '@/schemas/planet' const openAPIHandler = new OpenAPIHandler(router, { interceptors: [ @@ -22,6 +25,15 @@ const openAPIHandler = new OpenAPIHandler(router, { title: 'ORPC Playground', version: '1.0.0', }, + commonSchemas: { + NewUser: { schema: NewUserSchema }, + User: { schema: UserSchema }, + Credential: { schema: CredentialSchema }, + Token: { schema: TokenSchema }, + NewPlanet: { schema: NewPlanetSchema }, + UpdatePlanet: { schema: UpdatePlanetSchema }, + Planet: { schema: PlanetSchema }, + }, security: [{ bearerAuth: [] }], components: { securitySchemes: { diff --git a/playgrounds/nuxt/server/routes/api/[...].ts b/playgrounds/nuxt/server/routes/api/[...].ts index 86366f659..b38baab7b 100644 --- a/playgrounds/nuxt/server/routes/api/[...].ts +++ b/playgrounds/nuxt/server/routes/api/[...].ts @@ -3,6 +3,9 @@ import { onError } from '@orpc/server' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from '~/server/router' import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' +import { NewUserSchema, UserSchema } from '~/server/schemas/user' +import { CredentialSchema, TokenSchema } from '~/server/schemas/auth' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from '~/server/schemas/planet' const openAPIHandler = new OpenAPIHandler(router, { interceptors: [ @@ -21,6 +24,15 @@ const openAPIHandler = new OpenAPIHandler(router, { title: 'ORPC Playground', version: '1.0.0', }, + commonSchemas: { + NewUser: { schema: NewUserSchema }, + User: { schema: UserSchema }, + Credential: { schema: CredentialSchema }, + Token: { schema: TokenSchema }, + NewPlanet: { schema: NewPlanetSchema }, + UpdatePlanet: { schema: UpdatePlanetSchema }, + Planet: { schema: PlanetSchema }, + }, security: [{ bearerAuth: [] }], components: { securitySchemes: { diff --git a/playgrounds/solid-start/src/routes/api/[...rest].ts b/playgrounds/solid-start/src/routes/api/[...rest].ts index 00c492fea..bd153a8a7 100644 --- a/playgrounds/solid-start/src/routes/api/[...rest].ts +++ b/playgrounds/solid-start/src/routes/api/[...rest].ts @@ -5,6 +5,9 @@ import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { onError } from '@orpc/server' import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import '~/polyfill' +import { NewUserSchema, UserSchema } from '~/schemas/user' +import { CredentialSchema, TokenSchema } from '~/schemas/auth' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from '~/schemas/planet' const handler = new OpenAPIHandler(router, { interceptors: [ @@ -23,6 +26,15 @@ const handler = new OpenAPIHandler(router, { title: 'ORPC Playground', version: '1.0.0', }, + commonSchemas: { + NewUser: { schema: NewUserSchema }, + User: { schema: UserSchema }, + Credential: { schema: CredentialSchema }, + Token: { schema: TokenSchema }, + NewPlanet: { schema: NewPlanetSchema }, + UpdatePlanet: { schema: UpdatePlanetSchema }, + Planet: { schema: PlanetSchema }, + }, security: [{ bearerAuth: [] }], components: { securitySchemes: { diff --git a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts index 6274e0c56..a2e3be57f 100644 --- a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts +++ b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts @@ -5,6 +5,9 @@ import type { RequestHandler } from '@sveltejs/kit' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import '../../../polyfill' +import { NewUserSchema, UserSchema } from '../../../schemas/user' +import { CredentialSchema, TokenSchema } from '../../../schemas/auth' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from '../../../schemas/planet' const handler = new OpenAPIHandler(router, { interceptors: [ @@ -23,6 +26,15 @@ const handler = new OpenAPIHandler(router, { title: 'ORPC Playground', version: '1.0.0', }, + commonSchemas: { + NewUser: { schema: NewUserSchema }, + User: { schema: UserSchema }, + Credential: { schema: CredentialSchema }, + Token: { schema: TokenSchema }, + NewPlanet: { schema: NewPlanetSchema }, + UpdatePlanet: { schema: UpdatePlanetSchema }, + Planet: { schema: PlanetSchema }, + }, security: [{ bearerAuth: [] }], components: { securitySchemes: { diff --git a/playgrounds/tanstack-start/src/routes/api/$.ts b/playgrounds/tanstack-start/src/routes/api/$.ts index a2a310eb7..953a70cb2 100644 --- a/playgrounds/tanstack-start/src/routes/api/$.ts +++ b/playgrounds/tanstack-start/src/routes/api/$.ts @@ -6,6 +6,9 @@ import { createServerFileRoute } from '@tanstack/react-start/server' import { router } from '~/router/index' import { onError } from '@orpc/server' import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' +import { NewUserSchema, UserSchema } from '~/schemas/user' +import { CredentialSchema, TokenSchema } from '~/schemas/auth' +import { NewPlanetSchema, PlanetSchema, UpdatePlanetSchema } from '~/schemas/planet' const handler = new OpenAPIHandler(router, { interceptors: [ @@ -24,6 +27,15 @@ const handler = new OpenAPIHandler(router, { title: 'ORPC Playground', version: '1.0.0', }, + commonSchemas: { + NewUser: { schema: NewUserSchema }, + User: { schema: UserSchema }, + Credential: { schema: CredentialSchema }, + Token: { schema: TokenSchema }, + NewPlanet: { schema: NewPlanetSchema }, + UpdatePlanet: { schema: UpdatePlanetSchema }, + Planet: { schema: PlanetSchema }, + }, security: [{ bearerAuth: [] }], components: { securitySchemes: {