Skip to content

Commit 87d68ea

Browse files
authored
Fix OpenAPI oneOf/allOf merge (#3844)
1 parent 5f9c80e commit 87d68ea

File tree

6 files changed

+243
-69
lines changed

6 files changed

+243
-69
lines changed

.changeset/some-bags-wish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
---
4+
5+
Fix OpenAPI oneOf/allOf merge

packages/react-openapi/src/OpenAPISchema.tsx

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ export function OpenAPISchemaPresentation(props: {
382382
<div id={id} className="openapi-schema-presentation">
383383
<OpenAPISchemaName
384384
schema={schema}
385-
type={getSchemaTitle(schema)}
385+
type={getSchemaTitle(schema, { ignoreAlternatives: !propertyName })}
386386
propertyName={propertyName}
387387
isDiscriminatorProperty={isDiscriminatorProperty}
388388
required={required}
@@ -688,34 +688,90 @@ function flattenAlternatives(
688688
): OpenAPIV3.SchemaObject[] {
689689
// Get the parent schema's required fields from the most recent ancestor
690690
const latestAncestor = Array.from(ancestors).pop();
691+
const result: OpenAPIV3.SchemaObject[] = [];
691692

692-
return schemasOrRefs.reduce<OpenAPIV3.SchemaObject[]>((acc, schemaOrRef) => {
693+
for (const schemaOrRef of schemasOrRefs) {
693694
if (checkIsReference(schemaOrRef)) {
694-
return acc;
695+
continue;
695696
}
696697

697-
if (schemaOrRef[alternativeType] && !ancestors.has(schemaOrRef)) {
698-
const alternatives = getSchemaAlternatives(schemaOrRef, ancestors);
699-
if (alternatives?.schemas) {
700-
acc.push(
701-
...alternatives.schemas.map((schema) => ({
702-
...schema,
703-
required: mergeRequiredFields(schema, latestAncestor),
704-
}))
705-
);
698+
const flattened = flattenSchema(schemaOrRef, alternativeType, ancestors, latestAncestor);
699+
700+
if (flattened) {
701+
result.push(...flattened);
702+
}
703+
}
704+
705+
return result;
706+
}
707+
708+
/**
709+
* Flatten a schema that is an alternative of another schema.
710+
*/
711+
function flattenSchema(
712+
schema: OpenAPIV3.SchemaObject,
713+
alternativeType: AlternativeType,
714+
ancestors: Set<OpenAPIV3.SchemaObject>,
715+
latestAncestor: OpenAPIV3.SchemaObject | undefined
716+
): OpenAPIV3.SchemaObject[] {
717+
if (schema[alternativeType] && !ancestors.has(schema)) {
718+
const alternatives = getSchemaAlternatives(schema, ancestors);
719+
if (alternatives?.schemas) {
720+
return alternatives.schemas.map((s) => {
721+
const required = mergeRequiredFields(s, latestAncestor);
722+
return {
723+
...s,
724+
...(required ? { required } : {}),
725+
};
726+
});
727+
}
728+
729+
const required = mergeRequiredFields(schema, latestAncestor);
730+
return [{ ...schema, ...(required ? { required } : {}) }];
731+
}
732+
733+
// if a schema has allOf that can be safely merged, merge it
734+
if (
735+
(alternativeType === 'oneOf' || alternativeType === 'anyOf') &&
736+
schema.allOf &&
737+
Array.isArray(schema.allOf) &&
738+
!ancestors.has(schema)
739+
) {
740+
const allOfSchemas = schema.allOf.filter(
741+
(s): s is OpenAPIV3.SchemaObject => !checkIsReference(s)
742+
);
743+
744+
if (allOfSchemas.length > 0) {
745+
const merged = mergeAlternatives('allOf', allOfSchemas);
746+
if (merged && merged.length > 0) {
747+
// Only merge if all schemas were successfully merged into one (safe to merge)
748+
if (merged.length === 1) {
749+
return merged.map((s) => {
750+
const required = mergeRequiredFields(s, latestAncestor);
751+
const result: OpenAPIV3.SchemaObject = {
752+
...s,
753+
...(required ? { required } : {}),
754+
};
755+
756+
if (schema.title && !s.title) {
757+
result.title = schema.title;
758+
}
759+
760+
return result;
761+
});
762+
}
706763
}
707-
return acc;
708764
}
765+
}
709766

710-
// For direct schemas, handle required fields
711-
const schema = {
712-
...schemaOrRef,
713-
required: mergeRequiredFields(schemaOrRef, latestAncestor),
714-
};
767+
const required = mergeRequiredFields(schema, latestAncestor);
715768

716-
acc.push(schema);
717-
return acc;
718-
}, []);
769+
return [
770+
{
771+
...schema,
772+
...(required ? { required } : {}),
773+
},
774+
];
719775
}
720776

721777
/**

packages/react-openapi/src/contentTypeChecks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ export function isFormData(contentType?: string): boolean {
3434
return !!contentType && contentType.toLowerCase().includes('multipart/form-data');
3535
}
3636

37-
export function isPlainObject(value: unknown): boolean {
37+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
3838
return typeof value === 'object' && value !== null && !Array.isArray(value);
3939
}

packages/react-openapi/src/generateSchemaExample.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,4 +1037,90 @@ describe('generateSchemaExample', () => {
10371037
})
10381038
).toBeUndefined();
10391039
});
1040+
1041+
it('merges object properties from oneOf -> allOf', () => {
1042+
const schema = {
1043+
type: 'object',
1044+
properties: {
1045+
discriminator: {
1046+
type: 'string',
1047+
},
1048+
},
1049+
oneOf: [
1050+
{
1051+
allOf: [
1052+
{
1053+
type: 'object',
1054+
properties: {
1055+
bar: {
1056+
type: 'string',
1057+
},
1058+
},
1059+
},
1060+
{
1061+
type: 'object',
1062+
properties: {
1063+
baz: {
1064+
type: 'number',
1065+
},
1066+
},
1067+
},
1068+
{
1069+
type: 'string', // This will return a string, but should be ignored
1070+
},
1071+
],
1072+
},
1073+
],
1074+
} satisfies OpenAPIV3.SchemaObject;
1075+
1076+
const result = generateSchemaExample(schema);
1077+
1078+
expect(result).toBeDefined();
1079+
expect(result).toHaveProperty('discriminator');
1080+
expect(result).toHaveProperty('bar');
1081+
expect(result).toHaveProperty('baz');
1082+
});
1083+
1084+
it('merges object properties from anyOf -> allOf', () => {
1085+
const schema = {
1086+
type: 'object',
1087+
properties: {
1088+
discriminator: {
1089+
type: 'string',
1090+
},
1091+
},
1092+
anyOf: [
1093+
{
1094+
allOf: [
1095+
{
1096+
type: 'object',
1097+
properties: {
1098+
bar: {
1099+
type: 'string',
1100+
},
1101+
},
1102+
},
1103+
{
1104+
type: 'object',
1105+
properties: {
1106+
baz: {
1107+
type: 'number',
1108+
},
1109+
},
1110+
},
1111+
{
1112+
type: 'string', // This will return a string, but should be ignored
1113+
},
1114+
],
1115+
},
1116+
],
1117+
} satisfies OpenAPIV3.SchemaObject;
1118+
1119+
const result = generateSchemaExample(schema);
1120+
1121+
expect(result).toBeDefined();
1122+
expect(result).toHaveProperty('discriminator');
1123+
expect(result).toHaveProperty('bar');
1124+
expect(result).toHaveProperty('baz');
1125+
});
10401126
});

packages/react-openapi/src/generateSchemaExample.ts

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
2+
import { isPlainObject } from './contentTypeChecks';
23
import { checkIsReference } from './utils';
34

45
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
@@ -147,6 +148,23 @@ const getExampleFromSchema = (
147148
return result;
148149
}
149150

151+
// Process allOf items and merge object results into the response
152+
function mergeAllOfIntoResponse(
153+
allOfItems: Record<string, unknown>[],
154+
response: Record<string, unknown>,
155+
parent: Record<string, unknown> | undefined
156+
): void {
157+
const allOfResults = allOfItems
158+
.map((item: Record<string, unknown>) =>
159+
getExampleFromSchema(item, options, level + 1, parent, undefined, resultCache)
160+
)
161+
.filter(isPlainObject);
162+
163+
if (allOfResults.length > 0) {
164+
Object.assign(response, ...allOfResults);
165+
}
166+
}
167+
150168
// Check if the result is already cached
151169
if (resultCache.has(schema)) {
152170
return resultCache.get(schema);
@@ -307,45 +325,48 @@ const getExampleFromSchema = (
307325
}
308326

309327
if (schema.anyOf !== undefined) {
310-
Object.assign(
311-
response,
312-
getExampleFromSchema(
313-
schema.anyOf[0],
314-
options,
315-
level + 1,
316-
undefined,
317-
undefined,
318-
resultCache
319-
)
320-
);
328+
const anyOfItem = schema.anyOf[0];
329+
330+
if (anyOfItem) {
331+
// If anyOf[0] has allOf, process allOf items individually to merge object results
332+
if (anyOfItem?.allOf !== undefined && Array.isArray(anyOfItem.allOf)) {
333+
mergeAllOfIntoResponse(anyOfItem.allOf, response, anyOfItem);
334+
} else {
335+
const anyOfResult = getExampleFromSchema(
336+
anyOfItem,
337+
options,
338+
level + 1,
339+
undefined,
340+
undefined,
341+
resultCache
342+
);
343+
if (isPlainObject(anyOfResult)) {
344+
Object.assign(response, anyOfResult);
345+
}
346+
}
347+
}
321348
} else if (schema.oneOf !== undefined) {
322-
Object.assign(
323-
response,
324-
getExampleFromSchema(
325-
schema.oneOf[0],
326-
options,
327-
level + 1,
328-
undefined,
329-
undefined,
330-
resultCache
331-
)
332-
);
349+
const oneOfItem = schema.oneOf[0];
350+
if (oneOfItem) {
351+
// If oneOf[0] has allOf, process allOf items individually to merge object results
352+
if (oneOfItem?.allOf !== undefined && Array.isArray(oneOfItem.allOf)) {
353+
mergeAllOfIntoResponse(oneOfItem.allOf, response, oneOfItem);
354+
} else {
355+
const oneOfResult = getExampleFromSchema(
356+
oneOfItem,
357+
options,
358+
level + 1,
359+
undefined,
360+
undefined,
361+
resultCache
362+
);
363+
if (isPlainObject(oneOfResult)) {
364+
Object.assign(response, oneOfResult);
365+
}
366+
}
367+
}
333368
} else if (schema.allOf !== undefined) {
334-
Object.assign(
335-
response,
336-
...schema.allOf
337-
.map((item: Record<string, any>) =>
338-
getExampleFromSchema(
339-
item,
340-
options,
341-
level + 1,
342-
schema,
343-
undefined,
344-
resultCache
345-
)
346-
)
347-
.filter((item: any) => item !== undefined)
348-
);
369+
mergeAllOfIntoResponse(schema.allOf, response, schema);
349370
}
350371

351372
return cache(schema, response);

packages/react-openapi/src/utils.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -218,15 +218,18 @@ function getStatusCodeCategory(statusCode: number | string): number | string {
218218
return category;
219219
}
220220

221-
export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
221+
export function getSchemaTitle(
222+
schema: OpenAPIV3.SchemaObject,
223+
options?: { ignoreAlternatives?: boolean }
224+
): string {
222225
// Otherwise try to infer a nice title
223226
let type = 'any';
224227

225228
if (schema.enum || schema['x-enumDescriptions'] || schema['x-gitbook-enum']) {
226229
type = `${schema.type} · enum`;
227230
// check array AND schema.items as this is sometimes null despite what the type indicates
228231
} else if (schema.type === 'array' && !!schema.items) {
229-
type = `${getSchemaTitle(schema.items)}[]`;
232+
type = `${getSchemaTitle(schema.items, options)}[]`;
230233
} else if (Array.isArray(schema.type)) {
231234
type = schema.type.join(' | ');
232235
} else if (schema.type || schema.properties) {
@@ -242,14 +245,17 @@ export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
242245
}
243246
}
244247

245-
if ('anyOf' in schema) {
246-
type = 'any of';
247-
} else if ('oneOf' in schema) {
248-
type = 'one of';
249-
} else if ('allOf' in schema) {
250-
type = 'all of';
251-
} else if ('not' in schema) {
252-
type = 'not';
248+
// Skip alternative type labels if ignoreAlternatives is true (useful when rendering alternatives)
249+
if (!options?.ignoreAlternatives) {
250+
if ('anyOf' in schema) {
251+
type = 'any of';
252+
} else if ('oneOf' in schema) {
253+
type = 'one of';
254+
} else if ('allOf' in schema) {
255+
type = 'all of';
256+
} else if ('not' in schema) {
257+
type = 'not';
258+
}
253259
}
254260

255261
return type;

0 commit comments

Comments
 (0)