Skip to content

Commit 6424b57

Browse files
feat(openapi): expand support for unions/intersection of objects when generating OpenAPI specs (#1178)
Fixes #1162 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved OpenAPI generation: composed-object schemas and discriminated unions are now simplified earlier, producing clearer path/query/header parameters and request/response bodies across routes and methods; GET/query handling improved for optional fields. * **Tests** * Expanded test coverage for schema simplification, composed-object scenarios, discriminated unions, reference resolution, merging/deduplication and many edge cases. * **Chores** * Exposed a reusable schema-simplification utility for downstream use. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 4f6ce56 commit 6424b57

File tree

5 files changed

+892
-20
lines changed

5 files changed

+892
-20
lines changed

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

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,4 +1642,237 @@ describe('openAPIGenerator', () => {
16421642
},
16431643
})
16441644
})
1645+
1646+
it('expand support for union/interaction of object schemas in some cases', async () => {
1647+
const openAPIGenerator = new OpenAPIGenerator({
1648+
schemaConverters: [
1649+
new ZodToJsonSchemaConverter(),
1650+
],
1651+
})
1652+
1653+
const schema = z.discriminatedUnion('type', [
1654+
z.object({
1655+
type: z.literal('a'),
1656+
a: z.string(),
1657+
}),
1658+
z.object({
1659+
type: z.literal('b'),
1660+
b: z.number(),
1661+
}),
1662+
])
1663+
1664+
const router = {
1665+
ping: oc
1666+
.route({ path: '/{type}' })
1667+
.input(schema),
1668+
pong: oc.route({ method: 'GET' })
1669+
.input(schema),
1670+
peng: oc
1671+
.route({ path: '/{id}', inputStructure: 'detailed', outputStructure: 'detailed' })
1672+
.input(z.object({
1673+
params: z.union([z.object({ id: z.string() }), z.object({ id: z.number() })]),
1674+
query: schema,
1675+
headers: schema,
1676+
body: schema,
1677+
}))
1678+
.output(z.object({
1679+
headers: schema,
1680+
body: schema,
1681+
})),
1682+
}
1683+
1684+
const spec = await openAPIGenerator.generate(router)
1685+
1686+
expect(spec.paths!['/{type}']!.post).toEqual({
1687+
operationId: 'ping',
1688+
parameters: [
1689+
{
1690+
name: 'type',
1691+
in: 'path',
1692+
required: true,
1693+
schema: {
1694+
anyOf: [
1695+
{ const: 'a' },
1696+
{ const: 'b' },
1697+
],
1698+
},
1699+
},
1700+
],
1701+
requestBody: {
1702+
content: {
1703+
'application/json': {
1704+
schema: {
1705+
type: 'object',
1706+
properties: {
1707+
a: { type: 'string' },
1708+
b: { type: 'number' },
1709+
},
1710+
required: [],
1711+
},
1712+
},
1713+
},
1714+
required: false,
1715+
},
1716+
responses: expect.any(Object),
1717+
})
1718+
1719+
expect(spec.paths!['/pong']!.get).toEqual({
1720+
operationId: 'pong',
1721+
parameters: [
1722+
{
1723+
allowEmptyValue: true,
1724+
allowReserved: true,
1725+
name: 'type',
1726+
in: 'query',
1727+
required: true,
1728+
schema: {
1729+
anyOf: [
1730+
{ const: 'a' },
1731+
{ const: 'b' },
1732+
],
1733+
},
1734+
},
1735+
{
1736+
allowEmptyValue: true,
1737+
allowReserved: true,
1738+
name: 'a',
1739+
in: 'query',
1740+
schema: { type: 'string' },
1741+
required: false,
1742+
},
1743+
{
1744+
allowEmptyValue: true,
1745+
allowReserved: true,
1746+
name: 'b',
1747+
in: 'query',
1748+
schema: { type: 'number' },
1749+
required: false,
1750+
},
1751+
],
1752+
responses: expect.any(Object),
1753+
})
1754+
1755+
expect(spec.paths!['/{id}']!.post).toEqual({
1756+
operationId: 'peng',
1757+
parameters: [
1758+
{
1759+
name: 'id',
1760+
in: 'path',
1761+
required: true,
1762+
schema: {
1763+
anyOf: [
1764+
{
1765+
type: 'string',
1766+
},
1767+
{
1768+
type: 'number',
1769+
},
1770+
],
1771+
},
1772+
},
1773+
{
1774+
name: 'type',
1775+
in: 'query',
1776+
required: true,
1777+
schema: {
1778+
anyOf: [
1779+
{
1780+
const: 'a',
1781+
},
1782+
{
1783+
const: 'b',
1784+
},
1785+
],
1786+
},
1787+
allowEmptyValue: true,
1788+
allowReserved: true,
1789+
},
1790+
{
1791+
name: 'a',
1792+
in: 'query',
1793+
required: false,
1794+
schema: {
1795+
type: 'string',
1796+
},
1797+
allowEmptyValue: true,
1798+
allowReserved: true,
1799+
},
1800+
{
1801+
name: 'b',
1802+
in: 'query',
1803+
required: false,
1804+
schema: {
1805+
type: 'number',
1806+
},
1807+
allowEmptyValue: true,
1808+
allowReserved: true,
1809+
},
1810+
{
1811+
name: 'type',
1812+
in: 'header',
1813+
required: true,
1814+
schema: {
1815+
anyOf: [
1816+
{
1817+
const: 'a',
1818+
},
1819+
{
1820+
const: 'b',
1821+
},
1822+
],
1823+
},
1824+
},
1825+
{
1826+
name: 'a',
1827+
in: 'header',
1828+
required: false,
1829+
schema: {
1830+
type: 'string',
1831+
},
1832+
},
1833+
{
1834+
name: 'b',
1835+
in: 'header',
1836+
required: false,
1837+
schema: {
1838+
type: 'number',
1839+
},
1840+
},
1841+
],
1842+
requestBody: expect.any(Object),
1843+
responses: {
1844+
200: {
1845+
description: 'OK',
1846+
headers: {
1847+
type: {
1848+
schema: {
1849+
anyOf: [
1850+
{
1851+
const: 'a',
1852+
},
1853+
{
1854+
const: 'b',
1855+
},
1856+
],
1857+
},
1858+
required: true,
1859+
},
1860+
a: {
1861+
schema: {
1862+
type: 'string',
1863+
},
1864+
required: false,
1865+
},
1866+
b: {
1867+
schema: {
1868+
type: 'number',
1869+
},
1870+
required: false,
1871+
},
1872+
},
1873+
content: expect.any(Object),
1874+
},
1875+
},
1876+
})
1877+
})
16451878
})

packages/openapi/src/openapi-generator.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getDynamicParams, StandardOpenAPIJsonSerializer } from '@orpc/openapi-c
1111
import { resolveContractProcedures } from '@orpc/server'
1212
import { clone, stringifyJSON, toArray, value } from '@orpc/shared'
1313
import { applyCustomOpenAPIOperation } from './openapi-custom'
14-
import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils'
14+
import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, simplifyComposedObjectJsonSchemasAndRefs, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils'
1515
import { CompositeSchemaConverter } from './schema-converter'
1616
import { applySchemaOptionality, expandUnionSchema, isAnySchema, isObjectSchema, separateObjectSchema } from './schema-utils'
1717

@@ -304,14 +304,17 @@ export class OpenAPIGenerator {
304304
{
305305
...baseSchemaConvertOptions,
306306
strategy: 'input',
307-
minStructureDepthForRef: dynamicParams?.length || inputStructure === 'detailed' ? 1 : 0,
308307
},
309308
)
310309

311310
if (isAnySchema(schema) && !dynamicParams?.length) {
312311
return
313312
}
314313

314+
if (inputStructure === 'detailed' || (inputStructure === 'compact' && (dynamicParams?.length || method === 'GET'))) {
315+
schema = simplifyComposedObjectJsonSchemasAndRefs(schema, doc)
316+
}
317+
315318
if (inputStructure === 'compact') {
316319
if (dynamicParams?.length) {
317320
const error = new OpenAPIGeneratorError(
@@ -336,16 +339,14 @@ export class OpenAPIGenerator {
336339
}
337340

338341
if (method === 'GET') {
339-
const resolvedSchema = resolveOpenAPIJsonSchemaRef(doc, schema)
340-
341-
if (!isObjectSchema(resolvedSchema)) {
342+
if (!isObjectSchema(schema)) {
342343
throw new OpenAPIGeneratorError(
343344
'When method is "GET", input schema must satisfy: object | any | unknown',
344345
)
345346
}
346347

347348
ref.parameters ??= []
348-
ref.parameters.push(...toOpenAPIParameters(resolvedSchema, 'query'))
349+
ref.parameters.push(...toOpenAPIParameters(schema, 'query'))
349350
}
350351
else {
351352
ref.requestBody = {
@@ -367,7 +368,7 @@ export class OpenAPIGenerator {
367368
}
368369

369370
const resolvedParamSchema = schema.properties?.params !== undefined
370-
? resolveOpenAPIJsonSchemaRef(doc, schema.properties.params)
371+
? simplifyComposedObjectJsonSchemasAndRefs(schema.properties.params, doc)
371372
: undefined
372373

373374
if (
@@ -385,7 +386,7 @@ export class OpenAPIGenerator {
385386
for (const from of ['params', 'query', 'headers']) {
386387
const fromSchema = schema.properties?.[from]
387388
if (fromSchema !== undefined) {
388-
const resolvedSchema = resolveOpenAPIJsonSchemaRef(doc, fromSchema)
389+
const resolvedSchema = simplifyComposedObjectJsonSchemasAndRefs(fromSchema, doc)
389390

390391
if (!isObjectSchema(resolvedSchema)) {
391392
throw error
@@ -469,15 +470,17 @@ export class OpenAPIGenerator {
469470
But got: ${stringifyJSON(item)}
470471
`)
471472

472-
if (!isObjectSchema(item)) {
473+
const simplifiedItem = simplifyComposedObjectJsonSchemasAndRefs(item, doc)
474+
475+
if (!isObjectSchema(simplifiedItem)) {
473476
throw error
474477
}
475478

476479
let schemaStatus: number | undefined
477480
let schemaDescription: string | undefined
478481

479-
if (item.properties?.status !== undefined) {
480-
const statusSchema = resolveOpenAPIJsonSchemaRef(doc, item.properties.status)
482+
if (simplifiedItem.properties?.status !== undefined) {
483+
const statusSchema = resolveOpenAPIJsonSchemaRef(doc, simplifiedItem.properties.status)
481484

482485
if (typeof statusSchema !== 'object'
483486
|| statusSchema.const === undefined
@@ -509,8 +512,8 @@ export class OpenAPIGenerator {
509512
description: itemDescription,
510513
}
511514

512-
if (item.properties?.headers !== undefined) {
513-
const headersSchema = resolveOpenAPIJsonSchemaRef(doc, item.properties.headers)
515+
if (simplifiedItem.properties?.headers !== undefined) {
516+
const headersSchema = simplifyComposedObjectJsonSchemasAndRefs(simplifiedItem.properties.headers, doc)
514517

515518
if (!isObjectSchema(headersSchema)) {
516519
throw error
@@ -523,15 +526,15 @@ export class OpenAPIGenerator {
523526
ref.responses[itemStatus].headers ??= {}
524527
ref.responses[itemStatus].headers[key] = {
525528
schema: toOpenAPISchema(headerSchema) as any,
526-
required: item.required?.includes('headers') && headersSchema.required?.includes(key),
529+
required: simplifiedItem.required?.includes('headers') && headersSchema.required?.includes(key),
527530
}
528531
}
529532
}
530533
}
531534

532-
if (item.properties?.body !== undefined) {
535+
if (simplifiedItem.properties?.body !== undefined) {
533536
ref.responses[itemStatus].content = toOpenAPIContent(
534-
applySchemaOptionality(item.required?.includes('body') ?? false, item.properties.body),
537+
applySchemaOptionality(simplifiedItem.required?.includes('body') ?? false, simplifiedItem.properties.body),
535538
)
536539
}
537540
}

0 commit comments

Comments
 (0)