diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index a6ab62bb2..61d590a59 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -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), + }, + }, + }) + }) }) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index 00880ed8e..5f0f2ff6f 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 = { @@ -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 ( @@ -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 @@ -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 @@ -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 @@ -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), ) } } diff --git a/packages/openapi/src/openapi-utils.test.ts b/packages/openapi/src/openapi-utils.test.ts index a94747d59..aa5c6e8a3 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,482 @@ 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'], + }) + }) + + it('schema with object and composed schemas in the same level', () => { + expect(simplifyComposedObjectJsonSchemasAndRefs({ + type: 'object', + 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', () => { + 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..9887e9359 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) { + const required = objectUnionSchemas.every(s => s.required?.includes(key)) + + entry = { required, schemas: [] } + mergedUnionPropertyMap.set(key, entry) + } + entry.schemas.push(value) + } + } + } + + const intersectionSchemas = toArray(schema.allOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))) + const objectIntersectionSchemas: ObjectSchema[] = [] + for (const u of intersectionSchemas) { + if (!isObjectSchema(u)) { + return schema + } + + 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) { + for (const [key, value] of Object.entries(u.properties)) { + let entry = mergedInteractionPropertyMap.get(key) + if (!entry) { + const required = objectIntersectionSchemas.some(s => s.required?.includes(key)) + + entry = { required, schemas: [] } + mergedInteractionPropertyMap.set(key, entry) + } + + entry.schemas.push(value) + } + } + } + + 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?.required || 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)