Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions packages/openapi/src/openapi-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1642,4 +1642,237 @@ describe('openAPIGenerator', () => {
},
})
})

it('expand support for union/interaction of object schemas in some cases', async () => {
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [
new ZodToJsonSchemaConverter(),
],
})

const schema = z.discriminatedUnion('type', [
z.object({
type: z.literal('a'),
a: z.string(),
}),
z.object({
type: z.literal('b'),
b: z.number(),
}),
])

const router = {
ping: oc
.route({ path: '/{type}' })
.input(schema),
pong: oc.route({ method: 'GET' })
.input(schema),
peng: oc
.route({ path: '/{id}', inputStructure: 'detailed', outputStructure: 'detailed' })
.input(z.object({
params: z.union([z.object({ id: z.string() }), z.object({ id: z.number() })]),
query: schema,
headers: schema,
body: schema,
}))
.output(z.object({
headers: schema,
body: schema,
})),
}

const spec = await openAPIGenerator.generate(router)

expect(spec.paths!['/{type}']!.post).toEqual({
operationId: 'ping',
parameters: [
{
name: 'type',
in: 'path',
required: true,
schema: {
anyOf: [
{ const: 'a' },
{ const: 'b' },
],
},
},
],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
a: { type: 'string' },
b: { type: 'number' },
},
required: [],
},
},
},
required: false,
},
responses: expect.any(Object),
})

expect(spec.paths!['/pong']!.get).toEqual({
operationId: 'pong',
parameters: [
{
allowEmptyValue: true,
allowReserved: true,
name: 'type',
in: 'query',
required: true,
schema: {
anyOf: [
{ const: 'a' },
{ const: 'b' },
],
},
},
{
allowEmptyValue: true,
allowReserved: true,
name: 'a',
in: 'query',
schema: { type: 'string' },
required: false,
},
{
allowEmptyValue: true,
allowReserved: true,
name: 'b',
in: 'query',
schema: { type: 'number' },
required: false,
},
],
responses: expect.any(Object),
})

expect(spec.paths!['/{id}']!.post).toEqual({
operationId: 'peng',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: {
anyOf: [
{
type: 'string',
},
{
type: 'number',
},
],
},
},
{
name: 'type',
in: 'query',
required: true,
schema: {
anyOf: [
{
const: 'a',
},
{
const: 'b',
},
],
},
allowEmptyValue: true,
allowReserved: true,
},
{
name: 'a',
in: 'query',
required: false,
schema: {
type: 'string',
},
allowEmptyValue: true,
allowReserved: true,
},
{
name: 'b',
in: 'query',
required: false,
schema: {
type: 'number',
},
allowEmptyValue: true,
allowReserved: true,
},
{
name: 'type',
in: 'header',
required: true,
schema: {
anyOf: [
{
const: 'a',
},
{
const: 'b',
},
],
},
},
{
name: 'a',
in: 'header',
required: false,
schema: {
type: 'string',
},
},
{
name: 'b',
in: 'header',
required: false,
schema: {
type: 'number',
},
},
],
requestBody: expect.any(Object),
responses: {
200: {
description: 'OK',
headers: {
type: {
schema: {
anyOf: [
{
const: 'a',
},
{
const: 'b',
},
],
},
required: true,
},
a: {
schema: {
type: 'string',
},
required: false,
},
b: {
schema: {
type: 'number',
},
required: false,
},
},
content: expect.any(Object),
},
},
})
})
})
35 changes: 19 additions & 16 deletions packages/openapi/src/openapi-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getDynamicParams, StandardOpenAPIJsonSerializer } from '@orpc/openapi-c
import { resolveContractProcedures } from '@orpc/server'
import { clone, stringifyJSON, toArray, value } from '@orpc/shared'
import { applyCustomOpenAPIOperation } from './openapi-custom'
import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils'
import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, simplifyComposedObjectJsonSchemasAndRefs, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils'
import { CompositeSchemaConverter } from './schema-converter'
import { applySchemaOptionality, expandUnionSchema, isAnySchema, isObjectSchema, separateObjectSchema } from './schema-utils'

Expand Down Expand Up @@ -304,14 +304,17 @@ export class OpenAPIGenerator {
{
...baseSchemaConvertOptions,
strategy: 'input',
minStructureDepthForRef: dynamicParams?.length || inputStructure === 'detailed' ? 1 : 0,
},
)

if (isAnySchema(schema) && !dynamicParams?.length) {
return
}

if (inputStructure === 'detailed' || (inputStructure === 'compact' && (dynamicParams?.length || method === 'GET'))) {
schema = simplifyComposedObjectJsonSchemasAndRefs(schema, doc)
}

if (inputStructure === 'compact') {
if (dynamicParams?.length) {
const error = new OpenAPIGeneratorError(
Expand All @@ -336,16 +339,14 @@ export class OpenAPIGenerator {
}

if (method === 'GET') {
const resolvedSchema = resolveOpenAPIJsonSchemaRef(doc, schema)

if (!isObjectSchema(resolvedSchema)) {
if (!isObjectSchema(schema)) {
throw new OpenAPIGeneratorError(
'When method is "GET", input schema must satisfy: object | any | unknown',
)
}

ref.parameters ??= []
ref.parameters.push(...toOpenAPIParameters(resolvedSchema, 'query'))
ref.parameters.push(...toOpenAPIParameters(schema, 'query'))
}
else {
ref.requestBody = {
Expand All @@ -367,7 +368,7 @@ export class OpenAPIGenerator {
}

const resolvedParamSchema = schema.properties?.params !== undefined
? resolveOpenAPIJsonSchemaRef(doc, schema.properties.params)
? simplifyComposedObjectJsonSchemasAndRefs(schema.properties.params, doc)
: undefined

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

if (!isObjectSchema(resolvedSchema)) {
throw error
Expand Down Expand Up @@ -469,15 +470,17 @@ export class OpenAPIGenerator {
But got: ${stringifyJSON(item)}
`)

if (!isObjectSchema(item)) {
const simplifiedItem = simplifyComposedObjectJsonSchemasAndRefs(item, doc)

if (!isObjectSchema(simplifiedItem)) {
throw error
}

let schemaStatus: number | undefined
let schemaDescription: string | undefined

if (item.properties?.status !== undefined) {
const statusSchema = resolveOpenAPIJsonSchemaRef(doc, item.properties.status)
if (simplifiedItem.properties?.status !== undefined) {
const statusSchema = resolveOpenAPIJsonSchemaRef(doc, simplifiedItem.properties.status)

if (typeof statusSchema !== 'object'
|| statusSchema.const === undefined
Expand Down Expand Up @@ -509,8 +512,8 @@ export class OpenAPIGenerator {
description: itemDescription,
}

if (item.properties?.headers !== undefined) {
const headersSchema = resolveOpenAPIJsonSchemaRef(doc, item.properties.headers)
if (simplifiedItem.properties?.headers !== undefined) {
const headersSchema = simplifyComposedObjectJsonSchemasAndRefs(simplifiedItem.properties.headers, doc)

if (!isObjectSchema(headersSchema)) {
throw error
Expand All @@ -523,15 +526,15 @@ export class OpenAPIGenerator {
ref.responses[itemStatus].headers ??= {}
ref.responses[itemStatus].headers[key] = {
schema: toOpenAPISchema(headerSchema) as any,
required: item.required?.includes('headers') && headersSchema.required?.includes(key),
required: simplifiedItem.required?.includes('headers') && headersSchema.required?.includes(key),
}
}
}
}

if (item.properties?.body !== undefined) {
if (simplifiedItem.properties?.body !== undefined) {
ref.responses[itemStatus].content = toOpenAPIContent(
applySchemaOptionality(item.required?.includes('body') ?? false, item.properties.body),
applySchemaOptionality(simplifiedItem.required?.includes('body') ?? false, simplifiedItem.properties.body),
)
}
}
Expand Down
Loading
Loading