Skip to content

Commit f40acb5

Browse files
authored
Merge pull request #814 from BitGo/DX-501-merging-unions
feat: implement merging unions function with test cases
2 parents f1c8ad3 + 172a0dc commit f40acb5

File tree

2 files changed

+202
-2
lines changed

2 files changed

+202
-2
lines changed

packages/openapi-generator/src/optimize.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,47 @@ export function foldIntersection(schema: Schema, optimize: OptimizeFn): Schema {
3131
return result;
3232
}
3333

34+
function mergeUnions(schema: Schema): Schema {
35+
if (schema.type !== 'union') return schema;
36+
else if (schema.schemas.length === 1) return schema.schemas[0]!;
37+
else if (schema.schemas.length === 0) return { type: 'undefined' };
38+
39+
// Stringified schemas (i.e. hashes of the schemas) to avoid duplicates
40+
const resultingSchemas: Set<string> = new Set();
41+
42+
// Function to make the result of JSON.stringify deterministic (i.e. keys are all sorted alphabetically)
43+
const sortObj = (obj: object): object =>
44+
obj === null || typeof obj !== 'object'
45+
? obj
46+
: Array.isArray(obj)
47+
? obj.map(sortObj)
48+
: Object.assign(
49+
{},
50+
...Object.entries(obj)
51+
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
52+
.map(([k, v]) => ({ [k]: sortObj(v) })),
53+
);
54+
55+
// Deterministic version of JSON.stringify
56+
const deterministicStringify = (obj: object) => JSON.stringify(sortObj(obj));
57+
58+
schema.schemas.forEach((innerSchema) => {
59+
if (innerSchema.type === 'union') {
60+
const merged = mergeUnions(innerSchema);
61+
resultingSchemas.add(deterministicStringify(merged));
62+
} else {
63+
resultingSchemas.add(deterministicStringify(innerSchema));
64+
}
65+
});
66+
67+
if (resultingSchemas.size === 1) return JSON.parse(Array.from(resultingSchemas)[0]!);
68+
69+
return {
70+
type: 'union',
71+
schemas: Array.from(resultingSchemas).map((s) => JSON.parse(s)),
72+
};
73+
}
74+
3475
export function simplifyUnion(schema: Schema, optimize: OptimizeFn): Schema {
3576
if (schema.type !== 'union') {
3677
return schema;
@@ -134,11 +175,13 @@ export function optimize(schema: Schema): Schema {
134175
return newSchema;
135176
} else if (schema.type === 'union') {
136177
const simplified = simplifyUnion(schema, optimize);
178+
const merged = mergeUnions(simplified);
179+
137180
if (schema.comment) {
138-
return { ...simplified, comment: schema.comment };
181+
return { ...merged, comment: schema.comment };
139182
}
140183

141-
return simplified;
184+
return merged;
142185
} else if (schema.type === 'array') {
143186
const optimized = optimize(schema.items);
144187
if (schema.comment) {

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

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3298,3 +3298,160 @@ testCase('route with many response codes uses default status code descriptions',
32983298
}
32993299
}
33003300
});
3301+
3302+
const SCHEMA_WITH_REDUNDANT_UNIONS = `
3303+
import * as t from 'io-ts';
3304+
import * as h from '@api-ts/io-ts-http';
3305+
3306+
export const route = h.httpRoute({
3307+
path: '/foo',
3308+
method: 'GET',
3309+
request: h.httpRequest({
3310+
query: {
3311+
foo: t.union([t.string, t.string]),
3312+
bar: t.union([t.number, t.number, t.number]),
3313+
bucket: t.union([t.string, t.number, t.boolean, t.string, t.number, t.boolean]),
3314+
},
3315+
body: {
3316+
typeUnion: t.union([
3317+
t.type({ foo: t.string, bar: t.number }),
3318+
t.type({ bar: t.number, foo: t.string}),
3319+
]),
3320+
nestedTypeUnion: t.union([
3321+
t.type({ nested: t.type({ foo: t.string, bar: t.number }) }),
3322+
t.type({ nested: t.type({ foo: t.string, bar: t.number }) })
3323+
])
3324+
}
3325+
}),
3326+
response: {
3327+
200: t.union([t.string, t.string, t.union([t.number, t.number])]),
3328+
400: t.union([t.boolean, t.boolean, t.boolean])
3329+
},
3330+
})
3331+
`
3332+
3333+
testCase('route with reduntant response schemas', SCHEMA_WITH_REDUNDANT_UNIONS, {
3334+
openapi: '3.0.3',
3335+
info: {
3336+
title: 'Test',
3337+
version: '1.0.0'
3338+
},
3339+
paths: {
3340+
'/foo': {
3341+
get: {
3342+
parameters: [
3343+
{
3344+
in: 'query',
3345+
name: 'foo',
3346+
required: true,
3347+
schema: {
3348+
type: 'string'
3349+
}
3350+
},
3351+
{
3352+
in: 'query',
3353+
name: 'bar',
3354+
required: true,
3355+
schema: {
3356+
type: 'number'
3357+
}
3358+
},
3359+
{
3360+
in: 'query',
3361+
name: 'bucket',
3362+
required: true,
3363+
schema: {
3364+
oneOf: [
3365+
{ type: 'string' },
3366+
{ type: 'number' },
3367+
{ type: 'boolean' }
3368+
]
3369+
}
3370+
}
3371+
],
3372+
requestBody: {
3373+
content: {
3374+
'application/json': {
3375+
schema: {
3376+
properties: {
3377+
nestedTypeUnion: {
3378+
properties: {
3379+
nested: {
3380+
properties: {
3381+
bar: {
3382+
type: 'number'
3383+
},
3384+
foo: {
3385+
type: 'string'
3386+
}
3387+
},
3388+
required: [
3389+
'bar',
3390+
'foo'
3391+
],
3392+
type: 'object'
3393+
}
3394+
},
3395+
required: [
3396+
'nested'
3397+
],
3398+
type: 'object'
3399+
},
3400+
typeUnion: {
3401+
properties: {
3402+
bar: {
3403+
type: 'number'
3404+
},
3405+
foo: {
3406+
type: 'string'
3407+
}
3408+
},
3409+
required: [
3410+
'bar',
3411+
'foo'
3412+
],
3413+
type: 'object'
3414+
}
3415+
},
3416+
required: [
3417+
'typeUnion',
3418+
'nestedTypeUnion'
3419+
],
3420+
type: 'object'
3421+
}
3422+
}
3423+
}
3424+
},
3425+
responses: {
3426+
'200': {
3427+
description: 'OK',
3428+
content: {
3429+
'application/json': {
3430+
schema: {
3431+
oneOf: [{
3432+
type: 'string'
3433+
}, {
3434+
type: 'number'
3435+
}]
3436+
}
3437+
}
3438+
}
3439+
},
3440+
'400': {
3441+
description: 'Bad Request',
3442+
content: {
3443+
'application/json': {
3444+
schema: {
3445+
type: 'boolean'
3446+
}
3447+
}
3448+
}
3449+
}
3450+
}
3451+
}
3452+
}
3453+
},
3454+
components: {
3455+
schemas: {}
3456+
}
3457+
});

0 commit comments

Comments
 (0)