Skip to content

Commit c0ca4c7

Browse files
authored
fix(openapi): correct success response spec for optional body (#319)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced flexible API schema handling that supports optional fields for richer, adaptable output responses. - **Refactor** - Enhanced the documentation generation process to accurately distinguish between required and optional data. - **Tests** - Expanded test coverage to validate diverse response scenarios and ensure robust schema validations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 05a8f88 commit c0ca4c7

File tree

4 files changed

+93
-13
lines changed

4 files changed

+93
-13
lines changed

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

+65-6
Original file line numberDiff line numberDiff line change
@@ -504,12 +504,9 @@ const successResponseTests: TestCase[] = [
504504
contract: oc.route({ outputStructure: 'detailed' }).output(z.object({ headers: z.string() })),
505505
error: 'When output structure is "detailed", output schema must satisfy',
506506
},
507-
]
508-
509-
const errorResponseTests: TestCase[] = [
510507
{
511-
name: 'without errors',
512-
contract: oc,
508+
name: 'outputStructure=compact + output is optional',
509+
contract: oc.route({ outputStructure: 'compact' }).output(z.object({ name: z.string() }).optional()),
513510
expected: {
514511
'/': {
515512
post: expect.objectContaining({
@@ -518,7 +515,20 @@ const errorResponseTests: TestCase[] = [
518515
description: 'OK',
519516
content: {
520517
'application/json': {
521-
schema: {},
518+
schema: {
519+
anyOf: [
520+
{
521+
type: 'object',
522+
properties: {
523+
name: { type: 'string' },
524+
},
525+
required: ['name'],
526+
},
527+
{
528+
not: {},
529+
},
530+
],
531+
},
522532
},
523533
},
524534
},
@@ -527,6 +537,55 @@ const errorResponseTests: TestCase[] = [
527537
},
528538
},
529539
},
540+
{
541+
name: 'outputStructure=detailed + body is optional',
542+
contract: oc.route({ outputStructure: 'detailed' }).output(z.object({ body: z.object({ name: z.string() }).optional() })),
543+
expected: {
544+
'/': {
545+
post: expect.objectContaining({
546+
responses: {
547+
200: {
548+
description: 'OK',
549+
content: {
550+
'application/json': {
551+
schema: {
552+
anyOf: [
553+
{
554+
type: 'object',
555+
properties: {
556+
name: { type: 'string' },
557+
},
558+
required: ['name'],
559+
},
560+
{
561+
not: {},
562+
},
563+
],
564+
},
565+
},
566+
},
567+
},
568+
},
569+
}),
570+
},
571+
},
572+
},
573+
]
574+
575+
const errorResponseTests: TestCase[] = [
576+
{
577+
name: 'without errors',
578+
contract: oc,
579+
expected: {
580+
'/': {
581+
post: expect.objectContaining({
582+
responses: {
583+
200: expect.objectContaining({}),
584+
},
585+
}),
586+
},
587+
},
588+
},
530589
{
531590
name: 'with errors',
532591
contract: oc.errors({

packages/openapi/src/openapi-generator.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import { toHttpPath } from '@orpc/client/standard'
88
import { fallbackContractConfig, getEventIteratorSchemaDetails } from '@orpc/contract'
99
import { getDynamicParams, StandardOpenAPIJsonSerializer } from '@orpc/openapi-client/standard'
1010
import { resolveContractProcedures } from '@orpc/server'
11-
import { clone } from '@orpc/shared'
11+
import { clone, toArray } from '@orpc/shared'
1212
import { applyCustomOpenAPIOperation } from './openapi-custom'
1313
import { checkParamsSchema, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils'
1414
import { CompositeSchemaConverter, type ConditionalSchemaConverter, type SchemaConverter } from './schema-converter'
15-
import { isAnySchema, isObjectSchema, separateObjectSchema } from './schema-utils'
15+
import { applySchemaOptionality, isAnySchema, isObjectSchema, separateObjectSchema } from './schema-utils'
1616

1717
class OpenAPIGeneratorError extends Error {}
1818

@@ -26,7 +26,7 @@ export class OpenAPIGenerator {
2626

2727
constructor(options: OpenAPIGeneratorOptions = {}) {
2828
this.serializer = new StandardOpenAPIJsonSerializer(options)
29-
this.converter = new CompositeSchemaConverter(options.schemaConverters ?? [])
29+
this.converter = new CompositeSchemaConverter(toArray(options.schemaConverters))
3030
}
3131

3232
async generate(router: AnyContractRouter | AnyRouter, base: Omit<OpenAPI.Document, 'openapi'>): Promise<OpenAPI.Document> {
@@ -214,15 +214,15 @@ export class OpenAPIGenerator {
214214
return
215215
}
216216

217-
const [_, json] = this.converter.convert(outputSchema, { strategy: 'output' })
217+
const [required, json] = this.converter.convert(outputSchema, { strategy: 'output' })
218218

219219
ref.responses ??= {}
220220
ref.responses[status] = {
221221
description,
222222
}
223223

224224
if (outputStructure === 'compact') {
225-
ref.responses[status].content = toOpenAPIContent(json)
225+
ref.responses[status].content = toOpenAPIContent(applySchemaOptionality(required, json))
226226

227227
return
228228
}
@@ -251,7 +251,9 @@ export class OpenAPIGenerator {
251251
}
252252

253253
if (json.properties?.body !== undefined) {
254-
ref.responses[status].content = toOpenAPIContent(json.properties.body)
254+
ref.responses[status].content = toOpenAPIContent(
255+
applySchemaOptionality(json.required?.includes('body') ?? false, json.properties.body),
256+
)
255257
}
256258
}
257259

packages/openapi/src/schema-utils.test.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { JSONSchema, ObjectSchema } from './schema'
22
import { isObject } from '@orpc/shared'
3-
import { filterSchemaBranches, isAnySchema, isFileSchema, isObjectSchema, separateObjectSchema } from './schema-utils'
3+
import { applySchemaOptionality, filterSchemaBranches, isAnySchema, isFileSchema, isObjectSchema, separateObjectSchema } from './schema-utils'
44

55
it('isFileSchema', () => {
66
expect(isFileSchema({ type: 'string', contentMediaType: 'image/png' })).toBe(true)
@@ -196,3 +196,9 @@ describe('filterSchemaBranches', () => {
196196
})
197197
})
198198
})
199+
200+
it('applySchemaOptionality', () => {
201+
expect(applySchemaOptionality(true, { type: 'string' })).toEqual({ type: 'string' })
202+
expect(applySchemaOptionality(false, { type: 'string' })).toEqual({ anyOf: [{ type: 'string' }, { not: {} }] })
203+
expect(applySchemaOptionality(false, true)).toEqual({ anyOf: [true, { not: {} }] })
204+
})

packages/openapi/src/schema-utils.ts

+13
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,16 @@ export function filterSchemaBranches(
123123

124124
return [matches, schema]
125125
}
126+
127+
export function applySchemaOptionality(required: boolean, schema: JSONSchema): JSONSchema {
128+
if (required) {
129+
return schema
130+
}
131+
132+
return {
133+
anyOf: [
134+
schema,
135+
{ not: {} },
136+
],
137+
}
138+
}

0 commit comments

Comments
 (0)