diff --git a/packages/openapi-generator/src/optimize.ts b/packages/openapi-generator/src/optimize.ts index e70d124c..0c08d423 100644 --- a/packages/openapi-generator/src/optimize.ts +++ b/packages/openapi-generator/src/optimize.ts @@ -31,6 +31,47 @@ export function foldIntersection(schema: Schema, optimize: OptimizeFn): Schema { return result; } +function mergeUnions(schema: Schema): Schema { + if (schema.type !== 'union') return schema; + else if (schema.schemas.length === 1) return schema.schemas[0]!; + else if (schema.schemas.length === 0) return { type: 'undefined' }; + + // Stringified schemas (i.e. hashes of the schemas) to avoid duplicates + const resultingSchemas: Set = new Set(); + + // Function to make the result of JSON.stringify deterministic (i.e. keys are all sorted alphabetically) + const sortObj = (obj: object): object => + obj === null || typeof obj !== 'object' + ? obj + : Array.isArray(obj) + ? obj.map(sortObj) + : Object.assign( + {}, + ...Object.entries(obj) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([k, v]) => ({ [k]: sortObj(v) })), + ); + + // Deterministic version of JSON.stringify + const deterministicStringify = (obj: object) => JSON.stringify(sortObj(obj)); + + schema.schemas.forEach((innerSchema) => { + if (innerSchema.type === 'union') { + const merged = mergeUnions(innerSchema); + resultingSchemas.add(deterministicStringify(merged)); + } else { + resultingSchemas.add(deterministicStringify(innerSchema)); + } + }); + + if (resultingSchemas.size === 1) return JSON.parse(Array.from(resultingSchemas)[0]!); + + return { + type: 'union', + schemas: Array.from(resultingSchemas).map((s) => JSON.parse(s)), + }; +} + export function simplifyUnion(schema: Schema, optimize: OptimizeFn): Schema { if (schema.type !== 'union') { return schema; @@ -134,11 +175,13 @@ export function optimize(schema: Schema): Schema { return newSchema; } else if (schema.type === 'union') { const simplified = simplifyUnion(schema, optimize); + const merged = mergeUnions(simplified); + if (schema.comment) { - return { ...simplified, comment: schema.comment }; + return { ...merged, comment: schema.comment }; } - return simplified; + return merged; } else if (schema.type === 'array') { const optimized = optimize(schema.items); if (schema.comment) { diff --git a/packages/openapi-generator/test/openapi.test.ts b/packages/openapi-generator/test/openapi.test.ts index 9092e1d4..791c3134 100644 --- a/packages/openapi-generator/test/openapi.test.ts +++ b/packages/openapi-generator/test/openapi.test.ts @@ -3298,3 +3298,160 @@ testCase('route with many response codes uses default status code descriptions', } } }); + +const SCHEMA_WITH_REDUNDANT_UNIONS = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +export const route = h.httpRoute({ + path: '/foo', + method: 'GET', + request: h.httpRequest({ + query: { + foo: t.union([t.string, t.string]), + bar: t.union([t.number, t.number, t.number]), + bucket: t.union([t.string, t.number, t.boolean, t.string, t.number, t.boolean]), + }, + body: { + typeUnion: t.union([ + t.type({ foo: t.string, bar: t.number }), + t.type({ bar: t.number, foo: t.string}), + ]), + nestedTypeUnion: t.union([ + t.type({ nested: t.type({ foo: t.string, bar: t.number }) }), + t.type({ nested: t.type({ foo: t.string, bar: t.number }) }) + ]) + } + }), + response: { + 200: t.union([t.string, t.string, t.union([t.number, t.number])]), + 400: t.union([t.boolean, t.boolean, t.boolean]) + }, +}) +` + +testCase('route with reduntant response schemas', SCHEMA_WITH_REDUNDANT_UNIONS, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0' + }, + paths: { + '/foo': { + get: { + parameters: [ + { + in: 'query', + name: 'foo', + required: true, + schema: { + type: 'string' + } + }, + { + in: 'query', + name: 'bar', + required: true, + schema: { + type: 'number' + } + }, + { + in: 'query', + name: 'bucket', + required: true, + schema: { + oneOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' } + ] + } + } + ], + requestBody: { + content: { + 'application/json': { + schema: { + properties: { + nestedTypeUnion: { + properties: { + nested: { + properties: { + bar: { + type: 'number' + }, + foo: { + type: 'string' + } + }, + required: [ + 'bar', + 'foo' + ], + type: 'object' + } + }, + required: [ + 'nested' + ], + type: 'object' + }, + typeUnion: { + properties: { + bar: { + type: 'number' + }, + foo: { + type: 'string' + } + }, + required: [ + 'bar', + 'foo' + ], + type: 'object' + } + }, + required: [ + 'typeUnion', + 'nestedTypeUnion' + ], + type: 'object' + } + } + } + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + oneOf: [{ + type: 'string' + }, { + type: 'number' + }] + } + } + } + }, + '400': { + description: 'Bad Request', + content: { + 'application/json': { + schema: { + type: 'boolean' + } + } + } + } + } + } + } + }, + components: { + schemas: {} + } +}); \ No newline at end of file