From 5e17ca2faba086c8b820a9722e14c37bdced1acc Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Nov 2025 14:45:40 +0700 Subject: [PATCH 1/9] feat(openapi): expand support for unions/intersection of objects in parameters when generating OpenAPI specs Fixes #1162 --- .../openapi/src/openapi-generator.test.ts | 98 ++++ packages/openapi/src/openapi-generator.ts | 13 +- packages/openapi/src/openapi-utils.test.ts | 445 +++++++++++++++++- packages/openapi/src/openapi-utils.ts | 141 +++++- packages/openapi/src/schema-utils.test.ts | 12 +- 5 files changed, 699 insertions(+), 10 deletions(-) diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index a6ab62bb2..f3301b7f5 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -1642,4 +1642,102 @@ 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), + } + + 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), + }) + }) }) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index 00880ed8e..5f467119e 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -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' @@ -304,7 +304,6 @@ export class OpenAPIGenerator { { ...baseSchemaConvertOptions, strategy: 'input', - minStructureDepthForRef: dynamicParams?.length || inputStructure === 'detailed' ? 1 : 0, }, ) @@ -312,6 +311,10 @@ export class OpenAPIGenerator { return } + if (inputStructure === 'detailed' || (inputStructure === 'compact' && (dynamicParams?.length || method === 'GET'))) { + schema = simplifyComposedObjectJsonSchemasAndRefs(schema, doc) + } + if (inputStructure === 'compact') { if (dynamicParams?.length) { const error = new OpenAPIGeneratorError( @@ -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 = { diff --git a/packages/openapi/src/openapi-utils.test.ts b/packages/openapi/src/openapi-utils.test.ts index a94747d59..cbd261660 100644 --- a/packages/openapi/src/openapi-utils.test.ts +++ b/packages/openapi/src/openapi-utils.test.ts @@ -1,6 +1,16 @@ import type { OpenAPI } from '@orpc/contract' import type { FileSchema, JSONSchema, ObjectSchema } from './schema' -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' it('toOpenAPIPath', () => { expect(toOpenAPIPath('/path')).toBe('/path') @@ -373,3 +383,436 @@ describe('resolveOpenAPIJsonSchemaRef', () => { expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/not-found' })).toEqual({ $ref: '#/components/schemas/not-found' }) }) }) + +describe('simplifyComposedObjectJsonSchemasAndRefs', () => { + it('does not simplify non-object or non-composed schemas', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs(true)).toEqual(true) + expect(simplifyComposedObjectJsonSchemasAndRefs({ type: 'string' })).toEqual({ type: 'string' }) + expect(simplifyComposedObjectJsonSchemasAndRefs({ anyOf: [{ type: 'string' }, { type: 'number' }] })).toEqual({ anyOf: [{ type: 'string' }, { type: 'number' }] }) + expect(simplifyComposedObjectJsonSchemasAndRefs({ allOf: [{ type: 'array' }] })).toEqual({ allOf: [{ type: 'array' }] }) + + expect(simplifyComposedObjectJsonSchemasAndRefs({ + anyOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'number' }, + ], + })).toEqual({ + anyOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'number' }, + ], + }) + + expect(simplifyComposedObjectJsonSchemasAndRefs({ + description: 'description', + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: false, + })).toEqual({ + description: 'description', + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: false, + }) + }) + + it('only remain type, properties, required logics', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + anyOf: [ + { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + description: 'description a', + }, + { + type: 'object', + properties: { b: { type: 'number' } }, + required: ['b'], + additionalProperties: false, + }, + ], + description: 'object description', + additionalProperties: true, + })).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + }, + required: [], + }) + }) + + describe.each(['anyOf', 'oneOf'])('%s', (keyword) => { + it('ignore additional object logic', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + description: 'animal', + [keyword]: [ + { + type: 'object', + properties: { type: { const: 'pig' }, weight: { type: 'number' } }, + required: ['type', 'weight'], + additionalProperties: false, + }, + { + type: 'object', + properties: { type: { const: 'dog' }, barkVolume: { type: 'number' } }, + required: ['type', 'barkVolume'], + patternProperties: { + '^S_': { type: 'string' }, + '^I_': { type: 'integer' }, + }, + }, + ], + })).toEqual({ + type: 'object', + properties: { + type: { anyOf: [{ const: 'pig' }, { const: 'dog' }] }, + weight: { type: 'number' }, + barkVolume: { type: 'number' }, + }, + required: ['type'], + }) + }) + + it('handles empty', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + description: 'empty', + [keyword]: [], + })).toEqual({ + description: 'empty', + [keyword]: [], + }) + }) + + it('does not merge mixed object and non-object schemas', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + [keyword]: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'boolean' }, + ], + })).toEqual({ + [keyword]: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'boolean' }, + ], + }) + }) + + it('merges object schemas with discriminated union', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + description: 'animal', + [keyword]: [ + { + type: 'object', + properties: { type: { const: 'pig' }, weight: { type: 'number' } }, + required: ['type', 'weight'], + }, + { + type: 'object', + properties: { type: { const: 'dog' }, barkVolume: { type: 'number' } }, + required: ['type', 'barkVolume'], + }, + ], + })).toEqual({ + type: 'object', + properties: { + type: { anyOf: [{ const: 'pig' }, { const: 'dog' }] }, + weight: { type: 'number' }, + barkVolume: { type: 'number' }, + }, + required: ['type'], + }) + }) + + it('handle required & dedupe schemas correctly', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + [keyword]: [ + { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { a: { type: 'string' }, c: { type: 'string' } }, required: ['a', 'c'] }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + c: { type: 'string' }, + }, + required: ['a'], + }) + }) + + it('handles nested union recursively', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + [keyword]: [ + { [keyword]: [{ type: 'string' }, { type: 'number' }] }, + { type: 'boolean' }, + ], + })).toEqual({ + [keyword]: [ + { [keyword]: [{ type: 'string' }, { type: 'number' }] }, + { type: 'boolean' }, + ], + }) + }) + }) + + describe('allOf', () => { + it('handles empty', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + description: 'empty', + allOf: [], + })).toEqual({ + description: 'empty', + allOf: [], + }) + }) + + it('merges object schemas', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + allOf: [ + { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + }) + }) + + it('merges overlapping properties with allOf', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + allOf: [ + { type: 'object', properties: { a: { type: 'string', minLength: 1 } }, required: ['a'] }, + { type: 'object', properties: { a: { type: 'string', maxLength: 10 } }, required: ['a'] }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { allOf: [{ type: 'string', minLength: 1 }, { type: 'string', maxLength: 10 }] }, + }, + required: ['a'], + }) + }) + + it('handle required correctly & dedupe schemas', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + allOf: [ + { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { a: { type: 'string' }, c: { type: 'string' } }, required: ['a', 'c'] }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + c: { type: 'string' }, + }, + required: ['a', 'c'], + }) + }) + + it('handle nested compositions', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + allOf: [ + { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + }, + { type: 'object', properties: { c: { type: 'boolean' } }, required: ['c'] }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + c: { type: 'boolean' }, + }, + required: ['a', 'c'], + }) + }) + }) + + describe('combined compositions', () => { + it('recursively simplifies oneOf with nested allOf', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + oneOf: [ + { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }, + ], + }, + { + allOf: [ + { type: 'object', properties: { a: { type: 'number' } }, required: ['a'] }, + { type: 'object', properties: { c: { type: 'boolean' } }, required: ['c'] }, + ], + }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { anyOf: [{ type: 'string' }, { type: 'number' }] }, + b: { type: 'number' }, + c: { type: 'boolean' }, + }, + required: ['a'], + }) + }) + + it('recursively simplifies anyOf with nested allOf', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + anyOf: [ + { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }, + ], + }, + { + allOf: [ + { type: 'object', properties: { c: { type: 'boolean' } }, required: ['c'] }, + { type: 'object', properties: { d: { type: 'string' } }, required: ['d'] }, + ], + }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + c: { type: 'boolean' }, + d: { type: 'string' }, + }, + required: [], + }) + }) + + it('handles deeply nested compositions', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + anyOf: [ + { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + ], + }, + ], + })).toEqual({ + type: 'object', + properties: { a: { type: 'string' } }, + required: [], + }) + }) + + it('can simplify composed schemas with many compositions', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + anyOf: [{ type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }], + allOf: [{ type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }], + })).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + }) + }) + + it('dedupes schemas when anyOf and allOf coexist at the same level', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + allOf: [ + { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { a: { type: 'string' }, c: { type: 'string' } }, required: ['a', 'c'] }, + ], + anyOf: [ + { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { a: { type: 'number' }, d: { type: 'string' } }, required: ['a', 'd'] }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { + allOf: [ + { type: 'string' }, + { anyOf: [{ type: 'string' }, { type: 'number' }] }, + ], + }, + b: { type: 'string' }, + c: { type: 'string' }, + d: { type: 'string' }, + }, + required: ['a', 'c'], + }) + }) + }) + + describe('with $ref', () => { + const doc = { + components: { + schemas: { + Base: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + Extended: { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + ], + }, + }, + }, + } as any + + it('resolves $ref before simplifying', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs( + { $ref: '#/components/schemas/Extended' }, + doc, + )).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['id', 'name'], + }) + + expect(simplifyComposedObjectJsonSchemasAndRefs({ + allOf: [ + { $ref: '#/components/schemas/Base' }, + { + type: 'object', + properties: { + age: { type: 'number' }, + }, + required: ['age'], + }, + ], + }, doc)).toEqual({ + type: 'object', + properties: { + id: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['id', 'age'], + }) + }) + }) +}) diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index b3e2b1612..1daf336cb 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -2,8 +2,8 @@ import type { HTTPMethod, HTTPPath } from '@orpc/client' import type { OpenAPI } from '@orpc/contract' import type { FileSchema, JSONSchema, ObjectSchema } from './schema' import { standardizeHTTPPath } from '@orpc/openapi-client/standard' -import { findDeepMatches, isObject } from '@orpc/shared' -import { expandArrayableSchema, filterSchemaBranches, isFileSchema, isPrimitiveSchema } from './schema-utils' +import { findDeepMatches, isObject, stringifyJSON, toArray } from '@orpc/shared' +import { expandArrayableSchema, filterSchemaBranches, isFileSchema, isObjectSchema, isPrimitiveSchema } from './schema-utils' /** * @internal @@ -177,3 +177,140 @@ export function resolveOpenAPIJsonSchemaRef(doc: OpenAPI.Document, schema: JSONS const resolved = doc.components?.schemas?.[name] return resolved as JSONSchema ?? schema } + +/** + * Simplifies composed object JSON Schemas (using anyOf, oneOf, allOf) by flattening nested compositions + * + * @warning The result is looser than the original schema and may not fully validate the same data. + */ +export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc?: OpenAPI.Document): JSONSchema { + if (doc) { + schema = resolveOpenAPIJsonSchemaRef(doc, schema) + } + + if (typeof schema !== 'object' || (!schema.anyOf && !schema.oneOf && !schema.allOf)) { + return schema + } + + const unionSchemas = [ + ...toArray(schema.anyOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))), + ...toArray(schema.oneOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))), + ] + + const objectUnionSchemas: ObjectSchema[] = [] + for (const u of unionSchemas) { + if (!isObjectSchema(u)) { + return schema + } + + objectUnionSchemas.push(u) + } + + const mergedUnionPropertyMap: Map = new Map() + for (const u of objectUnionSchemas) { + if (u.properties) { + for (const [key, value] of Object.entries(u.properties)) { + let entry = mergedUnionPropertyMap.get(key) + if (!entry) { + entry = { required: false, schemas: [value] } + mergedUnionPropertyMap.set(key, entry) + } + else { + entry.schemas.push(value) + } + + if (!entry.required && objectUnionSchemas.every(s => s.required?.includes(key))) { + entry.required = true + } + } + } + } + + const intersectionSchemas = toArray(schema.allOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))) + const mergedInteractionPropertyMap: Map = new Map() + for (const u of intersectionSchemas) { + if (!isObjectSchema(u)) { + return schema + } + + if (u.properties) { + for (const [key, value] of Object.entries(u.properties)) { + let entry = mergedInteractionPropertyMap.get(key) + if (!entry) { + entry = { required: false, schemas: [value] } + mergedInteractionPropertyMap.set(key, entry) + } + else { + entry.schemas.push(value) + } + + if (u.required?.includes(key)) { + entry.required = true + } + } + } + } + + const resultObjectSchema: { type: 'object', properties: Record, required: string[] } = { type: 'object', properties: {}, required: [] } + + const keys = new Set([ + ...mergedUnionPropertyMap.keys(), + ...mergedInteractionPropertyMap.keys(), + ]) + + if (keys.size === 0) { + return schema + } + + const deduplicateSchemas = (schemas: JSONSchema[]): JSONSchema[] => { + const seen = new Set() + const result: JSONSchema[] = [] + for (const schema of schemas) { + const key = stringifyJSON(schema) + if (!seen.has(key)) { + seen.add(key) + result.push(schema) + } + } + return result + } + + for (const key of keys) { + const unionEntry = mergedUnionPropertyMap.get(key) + const intersectionEntry = mergedInteractionPropertyMap.get(key) + + resultObjectSchema.properties[key] = (() => { + const dedupedUnionSchemas = unionEntry ? deduplicateSchemas(unionEntry.schemas) : [] + const dedupedIntersectionSchemas = intersectionEntry ? deduplicateSchemas(intersectionEntry.schemas) : [] + + if (!dedupedUnionSchemas.length) { + return dedupedIntersectionSchemas.length === 1 + ? dedupedIntersectionSchemas[0]! + : { allOf: dedupedIntersectionSchemas } + } + + if (!dedupedIntersectionSchemas.length) { + return dedupedUnionSchemas.length === 1 + ? dedupedUnionSchemas[0]! + : { anyOf: dedupedUnionSchemas } + } + + const allOf = deduplicateSchemas([ + ...dedupedIntersectionSchemas, + dedupedUnionSchemas.length === 1 + ? dedupedUnionSchemas[0]! + : { anyOf: dedupedUnionSchemas }, + ]) + + return allOf.length === 1 + ? allOf[0]! + : { allOf } + })() + + if ((!unionEntry || unionEntry.required) && (!intersectionEntry || intersectionEntry.required)) { + resultObjectSchema.required.push(key) + } + } + + return resultObjectSchema +} diff --git a/packages/openapi/src/schema-utils.test.ts b/packages/openapi/src/schema-utils.test.ts index b5dd19a20..87fd2097a 100644 --- a/packages/openapi/src/schema-utils.test.ts +++ b/packages/openapi/src/schema-utils.test.ts @@ -1,6 +1,16 @@ import type { JSONSchema, ObjectSchema } from './schema' import { isObject } from '@orpc/shared' -import { applySchemaOptionality, expandArrayableSchema, expandUnionSchema, filterSchemaBranches, isAnySchema, isFileSchema, isObjectSchema, isPrimitiveSchema, separateObjectSchema } from './schema-utils' +import { + applySchemaOptionality, + expandArrayableSchema, + expandUnionSchema, + filterSchemaBranches, + isAnySchema, + isFileSchema, + isObjectSchema, + isPrimitiveSchema, + separateObjectSchema, +} from './schema-utils' it('isFileSchema', () => { expect(isFileSchema({ type: 'string', contentMediaType: 'image/png' })).toBe(true) From 38366e7a238493cbe15268213994a0c7de89a4e6 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Nov 2025 14:56:30 +0700 Subject: [PATCH 2/9] Update packages/openapi/src/openapi-utils.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/openapi/src/openapi-utils.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index 1daf336cb..8d93ef77c 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -212,20 +212,20 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc for (const [key, value] of Object.entries(u.properties)) { let entry = mergedUnionPropertyMap.get(key) if (!entry) { - entry = { required: false, schemas: [value] } + entry = { required: false, schemas: [] } mergedUnionPropertyMap.set(key, entry) } - else { - entry.schemas.push(value) - } - - if (!entry.required && objectUnionSchemas.every(s => s.required?.includes(key))) { - entry.required = true - } + entry.schemas.push(value) } } } + for (const [key, entry] of mergedUnionPropertyMap.entries()) { + if (objectUnionSchemas.every(s => s.required?.includes(key))) { + entry.required = true + } + } + const intersectionSchemas = toArray(schema.allOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))) const mergedInteractionPropertyMap: Map = new Map() for (const u of intersectionSchemas) { From 34f11f56c45760fc7685945d5646fd5bbebe196e Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Nov 2025 14:56:52 +0700 Subject: [PATCH 3/9] Update packages/openapi/src/openapi-utils.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/openapi/src/openapi-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index 8d93ef77c..030434f9b 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -307,7 +307,7 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc : { allOf } })() - if ((!unionEntry || unionEntry.required) && (!intersectionEntry || intersectionEntry.required)) { + if (unionEntry?.required || intersectionEntry?.required) { resultObjectSchema.required.push(key) } } From 0f449405f8800d489de30fae45ded33cb53169ca Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Nov 2025 15:09:15 +0700 Subject: [PATCH 4/9] improve --- packages/openapi/src/openapi-generator.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index 5f467119e..5f0f2ff6f 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -368,7 +368,7 @@ export class OpenAPIGenerator { } const resolvedParamSchema = schema.properties?.params !== undefined - ? resolveOpenAPIJsonSchemaRef(doc, schema.properties.params) + ? simplifyComposedObjectJsonSchemasAndRefs(schema.properties.params, doc) : undefined if ( @@ -386,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 @@ -470,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 @@ -510,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 @@ -524,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), ) } } From adce0d4a7af232442b85ce1678b5d0b415bea644 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Nov 2025 15:16:41 +0700 Subject: [PATCH 5/9] improve --- packages/openapi/src/openapi-utils.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index 030434f9b..d7f006abf 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -196,7 +196,6 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc ...toArray(schema.anyOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))), ...toArray(schema.oneOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))), ] - const objectUnionSchemas: ObjectSchema[] = [] for (const u of unionSchemas) { if (!isObjectSchema(u)) { @@ -219,45 +218,47 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc } } } - - for (const [key, entry] of mergedUnionPropertyMap.entries()) { + for (const [key, entry] of mergedUnionPropertyMap) { if (objectUnionSchemas.every(s => s.required?.includes(key))) { entry.required = true } } const intersectionSchemas = toArray(schema.allOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))) - const mergedInteractionPropertyMap: Map = new Map() + const objectIntersectionSchemas: ObjectSchema[] = [] for (const u of intersectionSchemas) { if (!isObjectSchema(u)) { return schema } + objectIntersectionSchemas.push(u) + } + + const mergedInteractionPropertyMap: Map = new Map() + for (const u of objectIntersectionSchemas) { if (u.properties) { for (const [key, value] of Object.entries(u.properties)) { let entry = mergedInteractionPropertyMap.get(key) if (!entry) { - entry = { required: false, schemas: [value] } + entry = { required: false, schemas: [] } mergedInteractionPropertyMap.set(key, entry) } - else { - entry.schemas.push(value) - } - if (u.required?.includes(key)) { - entry.required = true - } + entry.schemas.push(value) } } } + for (const [key, entry] of mergedInteractionPropertyMap) { + if (objectIntersectionSchemas.some(s => s.required?.includes(key))) { + entry.required = true + } + } const resultObjectSchema: { type: 'object', properties: Record, required: string[] } = { type: 'object', properties: {}, required: [] } - const keys = new Set([ ...mergedUnionPropertyMap.keys(), ...mergedInteractionPropertyMap.keys(), ]) - if (keys.size === 0) { return schema } From 5585fdb569592c04996c90356e426c7993c9cb24 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Nov 2025 15:36:59 +0700 Subject: [PATCH 6/9] improve --- packages/openapi/src/openapi-utils.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index d7f006abf..fea266d8e 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -211,18 +211,15 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc for (const [key, value] of Object.entries(u.properties)) { let entry = mergedUnionPropertyMap.get(key) if (!entry) { - entry = { required: false, schemas: [] } + const required = objectUnionSchemas.every(s => s.required?.includes(key)) + + entry = { required, schemas: [] } mergedUnionPropertyMap.set(key, entry) } entry.schemas.push(value) } } } - for (const [key, entry] of mergedUnionPropertyMap) { - if (objectUnionSchemas.every(s => s.required?.includes(key))) { - entry.required = true - } - } const intersectionSchemas = toArray(schema.allOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))) const objectIntersectionSchemas: ObjectSchema[] = [] @@ -240,7 +237,9 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc for (const [key, value] of Object.entries(u.properties)) { let entry = mergedInteractionPropertyMap.get(key) if (!entry) { - entry = { required: false, schemas: [] } + const required = objectIntersectionSchemas.some(s => s.required?.includes(key)) + + entry = { required, schemas: [] } mergedInteractionPropertyMap.set(key, entry) } @@ -248,11 +247,6 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc } } } - for (const [key, entry] of mergedInteractionPropertyMap) { - if (objectIntersectionSchemas.some(s => s.required?.includes(key))) { - entry.required = true - } - } const resultObjectSchema: { type: 'object', properties: Record, required: string[] } = { type: 'object', properties: {}, required: [] } const keys = new Set([ From 2d785def4b80b988826a5fe17b817175fec7712f Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 7 Nov 2025 08:51:45 +0700 Subject: [PATCH 7/9] tests --- .../openapi/src/openapi-generator.test.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index f3301b7f5..61d590a59 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -1667,6 +1667,18 @@ describe('openAPIGenerator', () => { .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) @@ -1739,5 +1751,128 @@ describe('openAPIGenerator', () => { ], 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), + }, + }, + }) }) }) From 3dcc5a5494763ece6025b98b9e8edad4cc9e350a Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 7 Nov 2025 09:48:15 +0700 Subject: [PATCH 8/9] improve --- packages/openapi/src/openapi-utils.test.ts | 45 ++++++++++++++++++++++ packages/openapi/src/openapi-utils.ts | 17 ++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/openapi/src/openapi-utils.test.ts b/packages/openapi/src/openapi-utils.test.ts index cbd261660..e350be8ba 100644 --- a/packages/openapi/src/openapi-utils.test.ts +++ b/packages/openapi/src/openapi-utils.test.ts @@ -752,6 +752,51 @@ describe('simplifyComposedObjectJsonSchemasAndRefs', () => { required: ['a', 'c'], }) }) + + it('schema with object and composed schemas in the same level', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + }, + required: ['a'], + anyOf: [ + { + type: 'object', + properties: { + b: { type: 'number' }, + c: { type: 'boolean' }, + }, + required: ['b', 'c'], + }, + { + type: 'object', + properties: { + c: { type: 'boolean' }, + }, + required: ['c'], + }, + ], + allOf: [ + { + type: 'object', + properties: { + f: { type: 'string' }, + }, + required: ['f'], + }, + ], + })).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { allOf: [{ type: 'string' }, { type: 'number' }] }, + c: { type: 'boolean' }, + f: { type: 'string' }, + }, + required: ['c', 'f', 'a'], + }) + }) }) describe('with $ref', () => { diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index fea266d8e..2fc3bc3b4 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -248,6 +248,23 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc } } + // if object schema in the same level with anyOf/oneOf/allOf + if (schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + let entry = mergedInteractionPropertyMap.get(key) + if (!entry) { + entry = { required: false, schemas: [] } + mergedInteractionPropertyMap.set(key, entry) + } + + entry.schemas.push(value) + + if (!entry.required && schema.required?.includes(key)) { + entry.required = true + } + } + } + const resultObjectSchema: { type: 'object', properties: Record, required: string[] } = { type: 'object', properties: {}, required: [] } const keys = new Set([ ...mergedUnionPropertyMap.keys(), From d5864915e6fd19919c491db861a126e6abe596a9 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 7 Nov 2025 10:08:32 +0700 Subject: [PATCH 9/9] improve --- packages/openapi/src/openapi-utils.test.ts | 1 + packages/openapi/src/openapi-utils.ts | 22 +++++----------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/openapi/src/openapi-utils.test.ts b/packages/openapi/src/openapi-utils.test.ts index e350be8ba..aa5c6e8a3 100644 --- a/packages/openapi/src/openapi-utils.test.ts +++ b/packages/openapi/src/openapi-utils.test.ts @@ -755,6 +755,7 @@ describe('simplifyComposedObjectJsonSchemasAndRefs', () => { it('schema with object and composed schemas in the same level', () => { expect(simplifyComposedObjectJsonSchemasAndRefs({ + type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' }, diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index 2fc3bc3b4..9887e9359 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -231,6 +231,11 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc objectIntersectionSchemas.push(u) } + // if object schema in the same level with anyOf/oneOf/allOf + if (isObjectSchema(schema)) { + objectIntersectionSchemas.push(schema) + } + const mergedInteractionPropertyMap: Map = new Map() for (const u of objectIntersectionSchemas) { if (u.properties) { @@ -248,23 +253,6 @@ export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc } } - // if object schema in the same level with anyOf/oneOf/allOf - if (schema.properties) { - for (const [key, value] of Object.entries(schema.properties)) { - let entry = mergedInteractionPropertyMap.get(key) - if (!entry) { - entry = { required: false, schemas: [] } - mergedInteractionPropertyMap.set(key, entry) - } - - entry.schemas.push(value) - - if (!entry.required && schema.required?.includes(key)) { - entry.required = true - } - } - } - const resultObjectSchema: { type: 'object', properties: Record, required: string[] } = { type: 'object', properties: {}, required: [] } const keys = new Set([ ...mergedUnionPropertyMap.keys(),