Skip to content

Commit 93fa264

Browse files
authored
feat(openapi): support UndefinedError in commonSchemas for spec generation (#655)
`UndefinedError` is used for undefined errors, which is very useful when using Type-Safe Error Handling. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced support for a reusable "UndefinedError" schema in OpenAPI specifications, enabling consistent and type-safe error handling across endpoints. - **Documentation** - Updated documentation to explain the "UndefinedError" schema, including expanded notes and clearer formatting. - **Tests** - Enhanced test coverage to validate referencing of the "UndefinedError" schema in error responses. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent b589620 commit 93fa264

11 files changed

Lines changed: 82 additions & 43 deletions

File tree

apps/content/docs/openapi/openapi-specification.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,19 @@ const spec = await generator.generate(router, {
136136
strategy: 'output',
137137
schema: PetSchema,
138138
},
139+
UndefinedError: {
140+
error: 'UndefinedError'
141+
}
139142
},
140143
})
141144
```
142145

143146
:::info
144-
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.
147+
148+
- 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.
149+
150+
- `UndefinedError` is used for undefined errors, which is very useful when using [Type-Safe Error Handling](/docs/error-handling#type‐safe-error-handling).
151+
145152
:::
146153

147154
## Excluding Procedures

packages/openapi/src/openapi-generator.test.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,9 @@ describe('openAPIGenerator', () => {
10821082
OutputDetailedStructure: {
10831083
schema: OutputDetailedStructure,
10841084
},
1085+
UndefinedError2: {
1086+
error: 'UndefinedError',
1087+
},
10851088
},
10861089
})
10871090

@@ -1155,11 +1158,22 @@ describe('openAPIGenerator', () => {
11551158
},
11561159
],
11571160
},
1161+
UndefinedError2: {
1162+
type: 'object',
1163+
properties: {
1164+
defined: { const: false },
1165+
code: { type: 'string' },
1166+
status: { type: 'number' },
1167+
message: { type: 'string' },
1168+
data: {},
1169+
},
1170+
required: ['defined', 'code', 'status', 'message'],
1171+
},
11581172
},
11591173
})
11601174
})
11611175

1162-
it('works with schema that input & output is same', async () => {
1176+
it('works with schema that input & output is same + error', async () => {
11631177
expect(spec.paths!['/user']).toEqual({
11641178
post: {
11651179
requestBody: {
@@ -1197,15 +1211,7 @@ describe('openAPIGenerator', () => {
11971211
required: ['defined', 'code', 'status', 'message', 'data'],
11981212
},
11991213
{
1200-
type: 'object',
1201-
properties: {
1202-
defined: { const: false },
1203-
code: { type: 'string' },
1204-
status: { type: 'number' },
1205-
message: { type: 'string' },
1206-
data: {},
1207-
},
1208-
required: ['defined', 'code', 'status', 'message'],
1214+
$ref: '#/components/schemas/UndefinedError2',
12091215
},
12101216
],
12111217
},
@@ -1218,7 +1224,7 @@ describe('openAPIGenerator', () => {
12181224
})
12191225
})
12201226

1221-
it('works with schema that input & output is different', async () => {
1227+
it('works with schema that input & output is different + error', async () => {
12221228
expect(spec.paths!['/pet']).toEqual({
12231229
post: {
12241230
operationId: 'pet',
@@ -1263,15 +1269,7 @@ describe('openAPIGenerator', () => {
12631269
required: ['defined', 'code', 'status', 'message', 'data'],
12641270
},
12651271
{
1266-
type: 'object',
1267-
properties: {
1268-
defined: { const: false },
1269-
code: { type: 'string' },
1270-
status: { type: 'number' },
1271-
message: { type: 'string' },
1272-
data: {},
1273-
},
1274-
required: ['defined', 'code', 'status', 'message'],
1272+
$ref: '#/components/schemas/UndefinedError2',
12751273
},
12761274
],
12771275
},

packages/openapi/src/openapi-generator.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, toOpenAPIContent, toOpe
1414
import { CompositeSchemaConverter } from './schema-converter'
1515
import { applySchemaOptionality, expandUnionSchema, isAnySchema, isObjectSchema, separateObjectSchema } from './schema-utils'
1616

17-
class OpenAPIGeneratorError extends Error {}
17+
class OpenAPIGeneratorError extends Error { }
1818

1919
export interface OpenAPIGeneratorOptions extends StandardOpenAPIJsonSerializerOptions {
2020
schemaConverters?: ConditionalSchemaConverter[]
@@ -55,6 +55,9 @@ export interface OpenAPIGeneratorGenerateOptions extends Partial<Omit<OpenAPI.Do
5555
*/
5656
strategy?: SchemaConvertOptions['strategy']
5757
schema: AnySchema
58+
} | {
59+
error: 'UndefinedError'
60+
schema?: never
5861
}>
5962
}
6063

@@ -88,7 +91,7 @@ export class OpenAPIGenerator {
8891
commonSchemas: undefined,
8992
} as OpenAPI.Document
9093

91-
const baseSchemaConvertOptions = await this.#resolveCommonSchemas(doc, options.commonSchemas)
94+
const { baseSchemaConvertOptions, undefinedErrorJsonSchema } = await this.#resolveCommonSchemas(doc, options.commonSchemas)
9295

9396
const contracts: { contract: AnyContractProcedure, path: readonly string[] }[] = []
9497

@@ -125,7 +128,7 @@ export class OpenAPIGenerator {
125128

126129
await this.#request(doc, operationObjectRef, def, baseSchemaConvertOptions)
127130
await this.#successResponse(doc, operationObjectRef, def, baseSchemaConvertOptions)
128-
await this.#errorResponse(operationObjectRef, def, baseSchemaConvertOptions)
131+
await this.#errorResponse(operationObjectRef, def, baseSchemaConvertOptions, undefinedErrorJsonSchema)
129132
}
130133

131134
doc.paths ??= {}
@@ -152,14 +155,34 @@ export class OpenAPIGenerator {
152155
return this.serializer.serialize(doc)[0] as OpenAPI.Document
153156
}
154157

155-
async #resolveCommonSchemas(doc: OpenAPI.Document, commonSchemas: OpenAPIGeneratorGenerateOptions['commonSchemas']): Promise<Pick<SchemaConvertOptions, 'components'>> {
156-
const baseOptions: { components?: SchemaConverterComponent[] } = {}
158+
async #resolveCommonSchemas(doc: OpenAPI.Document, commonSchemas: OpenAPIGeneratorGenerateOptions['commonSchemas']): Promise<{
159+
baseSchemaConvertOptions: Pick<SchemaConvertOptions, 'components'>
160+
undefinedErrorJsonSchema: JSONSchema
161+
}> {
162+
let undefinedErrorJsonSchema: JSONSchema = {
163+
type: 'object',
164+
properties: {
165+
defined: { const: false },
166+
code: { type: 'string' },
167+
status: { type: 'number' },
168+
message: { type: 'string' },
169+
data: {},
170+
},
171+
required: ['defined', 'code', 'status', 'message'],
172+
}
173+
const baseSchemaConvertOptions: { components?: SchemaConverterComponent[] } = {}
157174

158175
if (commonSchemas) {
159-
baseOptions.components = []
176+
baseSchemaConvertOptions.components = []
160177

161178
for (const key in commonSchemas) {
162-
const { schema, strategy = 'input' } = commonSchemas[key]!
179+
const options = commonSchemas[key]!
180+
181+
if (options.schema === undefined) {
182+
continue
183+
}
184+
185+
const { schema, strategy = 'input' } = options
163186

164187
const [required, json] = await this.converter.convert(schema, { strategy })
165188

@@ -180,7 +203,7 @@ export class OpenAPIGenerator {
180203
}
181204
}
182205

183-
baseOptions.components.push({
206+
baseSchemaConvertOptions.components.push({
184207
schema,
185208
required,
186209
ref: `#/components/schemas/${key}`,
@@ -192,11 +215,23 @@ export class OpenAPIGenerator {
192215
doc.components.schemas ??= {}
193216

194217
for (const key in commonSchemas) {
195-
const { schema, strategy = 'input' } = commonSchemas[key]!
218+
const options = commonSchemas[key]!
219+
220+
if (options.schema === undefined) {
221+
if (options.error === 'UndefinedError') {
222+
doc.components.schemas[key] = toOpenAPISchema(undefinedErrorJsonSchema)
223+
undefinedErrorJsonSchema = { $ref: `#/components/schemas/${key}` }
224+
}
225+
226+
continue
227+
}
228+
229+
const { schema, strategy = 'input' } = options
230+
196231
const [, json] = await this.converter.convert(
197232
schema,
198233
{
199-
...baseOptions,
234+
...baseSchemaConvertOptions,
200235
strategy,
201236
minStructureDepthForRef: 1, // not allow use $ref for root schemas
202237
},
@@ -205,7 +240,7 @@ export class OpenAPIGenerator {
205240
}
206241
}
207242

208-
return baseOptions
243+
return { baseSchemaConvertOptions, undefinedErrorJsonSchema }
209244
}
210245

211246
async #request(
@@ -472,6 +507,7 @@ export class OpenAPIGenerator {
472507
ref: OpenAPI.OperationObject,
473508
def: AnyContractProcedure['~orpc'],
474509
baseSchemaConvertOptions: Pick<SchemaConvertOptions, 'components'>,
510+
undefinedErrorSchema: JSONSchema,
475511
): Promise<void> {
476512
const errorMap = def.errorMap as ErrorMap
477513

@@ -513,17 +549,7 @@ export class OpenAPIGenerator {
513549
content: toOpenAPIContent({
514550
oneOf: [
515551
...schemas,
516-
{
517-
type: 'object',
518-
properties: {
519-
defined: { const: false },
520-
code: { type: 'string' },
521-
status: { type: 'number' },
522-
message: { type: 'string' },
523-
data: {},
524-
},
525-
required: ['defined', 'code', 'status', 'message'],
526-
},
552+
undefinedErrorSchema,
527553
],
528554
}),
529555
}

playgrounds/astro/src/pages/api/[...rest].ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const handler = new OpenAPIHandler(router, {
3434
NewPlanet: { schema: NewPlanetSchema },
3535
UpdatePlanet: { schema: UpdatePlanetSchema },
3636
Planet: { schema: PlanetSchema },
37+
UndefinedError: { error: 'UndefinedError' },
3738
},
3839
security: [{ bearerAuth: [] }],
3940
components: {

playgrounds/contract-first/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const openAPIHandler = new OpenAPIHandler(router, {
3535
NewPlanet: { schema: NewPlanetSchema },
3636
UpdatePlanet: { schema: UpdatePlanetSchema },
3737
Planet: { schema: PlanetSchema },
38+
UndefinedError: { error: 'UndefinedError' },
3839
},
3940
security: [{ bearerAuth: [] }],
4041
components: {

playgrounds/nest/src/reference/reference.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class ReferenceService {
3535
NewPlanet: { schema: NewPlanetSchema },
3636
UpdatePlanet: { schema: UpdatePlanetSchema },
3737
Planet: { schema: PlanetSchema },
38+
UndefinedError: { error: 'UndefinedError' },
3839
},
3940
servers: [
4041
{ url: 'http://localhost:3000' },

playgrounds/next/src/app/api/[[...rest]]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const openAPIHandler = new OpenAPIHandler(router, {
3333
NewPlanet: { schema: NewPlanetSchema },
3434
UpdatePlanet: { schema: UpdatePlanetSchema },
3535
Planet: { schema: PlanetSchema },
36+
UndefinedError: { error: 'UndefinedError' },
3637
},
3738
security: [{ bearerAuth: [] }],
3839
components: {

playgrounds/nuxt/server/routes/api/[...].ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const openAPIHandler = new OpenAPIHandler(router, {
3232
NewPlanet: { schema: NewPlanetSchema },
3333
UpdatePlanet: { schema: UpdatePlanetSchema },
3434
Planet: { schema: PlanetSchema },
35+
UndefinedError: { error: 'UndefinedError' },
3536
},
3637
security: [{ bearerAuth: [] }],
3738
components: {

playgrounds/solid-start/src/routes/api/[...rest].ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const handler = new OpenAPIHandler(router, {
3434
NewPlanet: { schema: NewPlanetSchema },
3535
UpdatePlanet: { schema: UpdatePlanetSchema },
3636
Planet: { schema: PlanetSchema },
37+
UndefinedError: { error: 'UndefinedError' },
3738
},
3839
security: [{ bearerAuth: [] }],
3940
components: {

playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const handler = new OpenAPIHandler(router, {
3434
NewPlanet: { schema: NewPlanetSchema },
3535
UpdatePlanet: { schema: UpdatePlanetSchema },
3636
Planet: { schema: PlanetSchema },
37+
UndefinedError: { error: 'UndefinedError' },
3738
},
3839
security: [{ bearerAuth: [] }],
3940
components: {

0 commit comments

Comments
 (0)