diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index 172c3bedf..d645b661c 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -136,12 +136,19 @@ const spec = await generator.generate(router, { strategy: 'output', schema: PetSchema, }, + UndefinedError: { + error: 'UndefinedError' + } }, }) ``` :::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. + +- 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. + +- `UndefinedError` is used for undefined errors, which is very useful when using [Type-Safe Error Handling](/docs/error-handling#type‐safe-error-handling). + ::: ## Excluding Procedures diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index 532d20909..9f602865a 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -1082,6 +1082,9 @@ describe('openAPIGenerator', () => { OutputDetailedStructure: { schema: OutputDetailedStructure, }, + UndefinedError2: { + error: 'UndefinedError', + }, }, }) @@ -1155,11 +1158,22 @@ describe('openAPIGenerator', () => { }, ], }, + UndefinedError2: { + type: 'object', + properties: { + defined: { const: false }, + code: { type: 'string' }, + status: { type: 'number' }, + message: { type: 'string' }, + data: {}, + }, + required: ['defined', 'code', 'status', 'message'], + }, }, }) }) - it('works with schema that input & output is same', async () => { + it('works with schema that input & output is same + error', async () => { expect(spec.paths!['/user']).toEqual({ post: { requestBody: { @@ -1197,15 +1211,7 @@ describe('openAPIGenerator', () => { 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'], + $ref: '#/components/schemas/UndefinedError2', }, ], }, @@ -1218,7 +1224,7 @@ describe('openAPIGenerator', () => { }) }) - it('works with schema that input & output is different', async () => { + it('works with schema that input & output is different + error', async () => { expect(spec.paths!['/pet']).toEqual({ post: { operationId: 'pet', @@ -1263,15 +1269,7 @@ describe('openAPIGenerator', () => { 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'], + $ref: '#/components/schemas/UndefinedError2', }, ], }, diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index ebb7a5a44..23c402dd0 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -14,7 +14,7 @@ import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, toOpenAPIContent, toOpe import { CompositeSchemaConverter } from './schema-converter' import { applySchemaOptionality, expandUnionSchema, isAnySchema, isObjectSchema, separateObjectSchema } from './schema-utils' -class OpenAPIGeneratorError extends Error {} +class OpenAPIGeneratorError extends Error { } export interface OpenAPIGeneratorOptions extends StandardOpenAPIJsonSerializerOptions { schemaConverters?: ConditionalSchemaConverter[] @@ -55,6 +55,9 @@ export interface OpenAPIGeneratorGenerateOptions extends Partial } @@ -88,7 +91,7 @@ export class OpenAPIGenerator { commonSchemas: undefined, } as OpenAPI.Document - const baseSchemaConvertOptions = await this.#resolveCommonSchemas(doc, options.commonSchemas) + const { baseSchemaConvertOptions, undefinedErrorJsonSchema } = await this.#resolveCommonSchemas(doc, options.commonSchemas) const contracts: { contract: AnyContractProcedure, path: readonly string[] }[] = [] @@ -125,7 +128,7 @@ export class OpenAPIGenerator { await this.#request(doc, operationObjectRef, def, baseSchemaConvertOptions) await this.#successResponse(doc, operationObjectRef, def, baseSchemaConvertOptions) - await this.#errorResponse(operationObjectRef, def, baseSchemaConvertOptions) + await this.#errorResponse(operationObjectRef, def, baseSchemaConvertOptions, undefinedErrorJsonSchema) } doc.paths ??= {} @@ -152,14 +155,34 @@ export class OpenAPIGenerator { return this.serializer.serialize(doc)[0] as OpenAPI.Document } - async #resolveCommonSchemas(doc: OpenAPI.Document, commonSchemas: OpenAPIGeneratorGenerateOptions['commonSchemas']): Promise> { - const baseOptions: { components?: SchemaConverterComponent[] } = {} + async #resolveCommonSchemas(doc: OpenAPI.Document, commonSchemas: OpenAPIGeneratorGenerateOptions['commonSchemas']): Promise<{ + baseSchemaConvertOptions: Pick + undefinedErrorJsonSchema: JSONSchema + }> { + let undefinedErrorJsonSchema: JSONSchema = { + type: 'object', + properties: { + defined: { const: false }, + code: { type: 'string' }, + status: { type: 'number' }, + message: { type: 'string' }, + data: {}, + }, + required: ['defined', 'code', 'status', 'message'], + } + const baseSchemaConvertOptions: { components?: SchemaConverterComponent[] } = {} if (commonSchemas) { - baseOptions.components = [] + baseSchemaConvertOptions.components = [] for (const key in commonSchemas) { - const { schema, strategy = 'input' } = commonSchemas[key]! + const options = commonSchemas[key]! + + if (options.schema === undefined) { + continue + } + + const { schema, strategy = 'input' } = options const [required, json] = await this.converter.convert(schema, { strategy }) @@ -180,7 +203,7 @@ export class OpenAPIGenerator { } } - baseOptions.components.push({ + baseSchemaConvertOptions.components.push({ schema, required, ref: `#/components/schemas/${key}`, @@ -192,11 +215,23 @@ export class OpenAPIGenerator { doc.components.schemas ??= {} for (const key in commonSchemas) { - const { schema, strategy = 'input' } = commonSchemas[key]! + const options = commonSchemas[key]! + + if (options.schema === undefined) { + if (options.error === 'UndefinedError') { + doc.components.schemas[key] = toOpenAPISchema(undefinedErrorJsonSchema) + undefinedErrorJsonSchema = { $ref: `#/components/schemas/${key}` } + } + + continue + } + + const { schema, strategy = 'input' } = options + const [, json] = await this.converter.convert( schema, { - ...baseOptions, + ...baseSchemaConvertOptions, strategy, minStructureDepthForRef: 1, // not allow use $ref for root schemas }, @@ -205,7 +240,7 @@ export class OpenAPIGenerator { } } - return baseOptions + return { baseSchemaConvertOptions, undefinedErrorJsonSchema } } async #request( @@ -472,6 +507,7 @@ export class OpenAPIGenerator { ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc'], baseSchemaConvertOptions: Pick, + undefinedErrorSchema: JSONSchema, ): Promise { const errorMap = def.errorMap as ErrorMap @@ -513,17 +549,7 @@ export class OpenAPIGenerator { content: toOpenAPIContent({ oneOf: [ ...schemas, - { - type: 'object', - properties: { - defined: { const: false }, - code: { type: 'string' }, - status: { type: 'number' }, - message: { type: 'string' }, - data: {}, - }, - required: ['defined', 'code', 'status', 'message'], - }, + undefinedErrorSchema, ], }), } diff --git a/playgrounds/astro/src/pages/api/[...rest].ts b/playgrounds/astro/src/pages/api/[...rest].ts index 75740bddb..cccfd0d6f 100644 --- a/playgrounds/astro/src/pages/api/[...rest].ts +++ b/playgrounds/astro/src/pages/api/[...rest].ts @@ -34,6 +34,7 @@ const handler = new OpenAPIHandler(router, { NewPlanet: { schema: NewPlanetSchema }, UpdatePlanet: { schema: UpdatePlanetSchema }, Planet: { schema: PlanetSchema }, + UndefinedError: { error: 'UndefinedError' }, }, security: [{ bearerAuth: [] }], components: { diff --git a/playgrounds/contract-first/src/main.ts b/playgrounds/contract-first/src/main.ts index 6f47f63d8..f175b7137 100644 --- a/playgrounds/contract-first/src/main.ts +++ b/playgrounds/contract-first/src/main.ts @@ -35,6 +35,7 @@ const openAPIHandler = new OpenAPIHandler(router, { NewPlanet: { schema: NewPlanetSchema }, UpdatePlanet: { schema: UpdatePlanetSchema }, Planet: { schema: PlanetSchema }, + UndefinedError: { error: 'UndefinedError' }, }, security: [{ bearerAuth: [] }], components: { diff --git a/playgrounds/nest/src/reference/reference.service.ts b/playgrounds/nest/src/reference/reference.service.ts index 8ac9b6ad3..ec998b2f5 100644 --- a/playgrounds/nest/src/reference/reference.service.ts +++ b/playgrounds/nest/src/reference/reference.service.ts @@ -35,6 +35,7 @@ export class ReferenceService { NewPlanet: { schema: NewPlanetSchema }, UpdatePlanet: { schema: UpdatePlanetSchema }, Planet: { schema: PlanetSchema }, + UndefinedError: { error: 'UndefinedError' }, }, 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 0e680aa2e..1a2ea4f8a 100644 --- a/playgrounds/next/src/app/api/[[...rest]]/route.ts +++ b/playgrounds/next/src/app/api/[[...rest]]/route.ts @@ -33,6 +33,7 @@ const openAPIHandler = new OpenAPIHandler(router, { NewPlanet: { schema: NewPlanetSchema }, UpdatePlanet: { schema: UpdatePlanetSchema }, Planet: { schema: PlanetSchema }, + UndefinedError: { error: 'UndefinedError' }, }, security: [{ bearerAuth: [] }], components: { diff --git a/playgrounds/nuxt/server/routes/api/[...].ts b/playgrounds/nuxt/server/routes/api/[...].ts index b38baab7b..1b1ba230d 100644 --- a/playgrounds/nuxt/server/routes/api/[...].ts +++ b/playgrounds/nuxt/server/routes/api/[...].ts @@ -32,6 +32,7 @@ const openAPIHandler = new OpenAPIHandler(router, { NewPlanet: { schema: NewPlanetSchema }, UpdatePlanet: { schema: UpdatePlanetSchema }, Planet: { schema: PlanetSchema }, + UndefinedError: { error: 'UndefinedError' }, }, security: [{ bearerAuth: [] }], components: { diff --git a/playgrounds/solid-start/src/routes/api/[...rest].ts b/playgrounds/solid-start/src/routes/api/[...rest].ts index bd153a8a7..f94c87680 100644 --- a/playgrounds/solid-start/src/routes/api/[...rest].ts +++ b/playgrounds/solid-start/src/routes/api/[...rest].ts @@ -34,6 +34,7 @@ const handler = new OpenAPIHandler(router, { NewPlanet: { schema: NewPlanetSchema }, UpdatePlanet: { schema: UpdatePlanetSchema }, Planet: { schema: PlanetSchema }, + UndefinedError: { error: 'UndefinedError' }, }, security: [{ bearerAuth: [] }], components: { diff --git a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts index a2e3be57f..49b52c2a1 100644 --- a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts +++ b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts @@ -34,6 +34,7 @@ const handler = new OpenAPIHandler(router, { NewPlanet: { schema: NewPlanetSchema }, UpdatePlanet: { schema: UpdatePlanetSchema }, Planet: { schema: PlanetSchema }, + UndefinedError: { error: 'UndefinedError' }, }, security: [{ bearerAuth: [] }], components: { diff --git a/playgrounds/tanstack-start/src/routes/api/$.ts b/playgrounds/tanstack-start/src/routes/api/$.ts index 953a70cb2..4791f57eb 100644 --- a/playgrounds/tanstack-start/src/routes/api/$.ts +++ b/playgrounds/tanstack-start/src/routes/api/$.ts @@ -35,6 +35,7 @@ const handler = new OpenAPIHandler(router, { NewPlanet: { schema: NewPlanetSchema }, UpdatePlanet: { schema: UpdatePlanetSchema }, Planet: { schema: PlanetSchema }, + UndefinedError: { error: 'UndefinedError' }, }, security: [{ bearerAuth: [] }], components: {