Fix OpenAPI oneOf/allOf merge (#3844)

This commit is contained in:
Nolann B.
2025-12-05 17:26:18 +01:00
committed by GitHub
parent 5f9c80e4f2
commit 87d68ea59e
6 changed files with 243 additions and 69 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix OpenAPI oneOf/allOf merge
+77 -21
View File
@@ -382,7 +382,7 @@ export function OpenAPISchemaPresentation(props: {
<div id={id} className="openapi-schema-presentation">
<OpenAPISchemaName
schema={schema}
type={getSchemaTitle(schema)}
type={getSchemaTitle(schema, { ignoreAlternatives: !propertyName })}
propertyName={propertyName}
isDiscriminatorProperty={isDiscriminatorProperty}
required={required}
@@ -688,34 +688,90 @@ function flattenAlternatives(
): OpenAPIV3.SchemaObject[] {
// Get the parent schema's required fields from the most recent ancestor
const latestAncestor = Array.from(ancestors).pop();
const result: OpenAPIV3.SchemaObject[] = [];
return schemasOrRefs.reduce<OpenAPIV3.SchemaObject[]>((acc, schemaOrRef) => {
for (const schemaOrRef of schemasOrRefs) {
if (checkIsReference(schemaOrRef)) {
return acc;
continue;
}
if (schemaOrRef[alternativeType] && !ancestors.has(schemaOrRef)) {
const alternatives = getSchemaAlternatives(schemaOrRef, ancestors);
if (alternatives?.schemas) {
acc.push(
...alternatives.schemas.map((schema) => ({
...schema,
required: mergeRequiredFields(schema, latestAncestor),
}))
);
const flattened = flattenSchema(schemaOrRef, alternativeType, ancestors, latestAncestor);
if (flattened) {
result.push(...flattened);
}
}
return result;
}
/**
* Flatten a schema that is an alternative of another schema.
*/
function flattenSchema(
schema: OpenAPIV3.SchemaObject,
alternativeType: AlternativeType,
ancestors: Set<OpenAPIV3.SchemaObject>,
latestAncestor: OpenAPIV3.SchemaObject | undefined
): OpenAPIV3.SchemaObject[] {
if (schema[alternativeType] && !ancestors.has(schema)) {
const alternatives = getSchemaAlternatives(schema, ancestors);
if (alternatives?.schemas) {
return alternatives.schemas.map((s) => {
const required = mergeRequiredFields(s, latestAncestor);
return {
...s,
...(required ? { required } : {}),
};
});
}
const required = mergeRequiredFields(schema, latestAncestor);
return [{ ...schema, ...(required ? { required } : {}) }];
}
// if a schema has allOf that can be safely merged, merge it
if (
(alternativeType === 'oneOf' || alternativeType === 'anyOf') &&
schema.allOf &&
Array.isArray(schema.allOf) &&
!ancestors.has(schema)
) {
const allOfSchemas = schema.allOf.filter(
(s): s is OpenAPIV3.SchemaObject => !checkIsReference(s)
);
if (allOfSchemas.length > 0) {
const merged = mergeAlternatives('allOf', allOfSchemas);
if (merged && merged.length > 0) {
// Only merge if all schemas were successfully merged into one (safe to merge)
if (merged.length === 1) {
return merged.map((s) => {
const required = mergeRequiredFields(s, latestAncestor);
const result: OpenAPIV3.SchemaObject = {
...s,
...(required ? { required } : {}),
};
if (schema.title && !s.title) {
result.title = schema.title;
}
return result;
});
}
}
return acc;
}
}
// For direct schemas, handle required fields
const schema = {
...schemaOrRef,
required: mergeRequiredFields(schemaOrRef, latestAncestor),
};
const required = mergeRequiredFields(schema, latestAncestor);
acc.push(schema);
return acc;
}, []);
return [
{
...schema,
...(required ? { required } : {}),
},
];
}
/**
@@ -34,6 +34,6 @@ export function isFormData(contentType?: string): boolean {
return !!contentType && contentType.toLowerCase().includes('multipart/form-data');
}
export function isPlainObject(value: unknown): boolean {
export function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -1037,4 +1037,90 @@ describe('generateSchemaExample', () => {
})
).toBeUndefined();
});
it('merges object properties from oneOf -> allOf', () => {
const schema = {
type: 'object',
properties: {
discriminator: {
type: 'string',
},
},
oneOf: [
{
allOf: [
{
type: 'object',
properties: {
bar: {
type: 'string',
},
},
},
{
type: 'object',
properties: {
baz: {
type: 'number',
},
},
},
{
type: 'string', // This will return a string, but should be ignored
},
],
},
],
} satisfies OpenAPIV3.SchemaObject;
const result = generateSchemaExample(schema);
expect(result).toBeDefined();
expect(result).toHaveProperty('discriminator');
expect(result).toHaveProperty('bar');
expect(result).toHaveProperty('baz');
});
it('merges object properties from anyOf -> allOf', () => {
const schema = {
type: 'object',
properties: {
discriminator: {
type: 'string',
},
},
anyOf: [
{
allOf: [
{
type: 'object',
properties: {
bar: {
type: 'string',
},
},
},
{
type: 'object',
properties: {
baz: {
type: 'number',
},
},
},
{
type: 'string', // This will return a string, but should be ignored
},
],
},
],
} satisfies OpenAPIV3.SchemaObject;
const result = generateSchemaExample(schema);
expect(result).toBeDefined();
expect(result).toHaveProperty('discriminator');
expect(result).toHaveProperty('bar');
expect(result).toHaveProperty('baz');
});
});
@@ -1,4 +1,5 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { isPlainObject } from './contentTypeChecks';
import { checkIsReference } from './utils';
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
@@ -147,6 +148,23 @@ const getExampleFromSchema = (
return result;
}
// Process allOf items and merge object results into the response
function mergeAllOfIntoResponse(
allOfItems: Record<string, unknown>[],
response: Record<string, unknown>,
parent: Record<string, unknown> | undefined
): void {
const allOfResults = allOfItems
.map((item: Record<string, unknown>) =>
getExampleFromSchema(item, options, level + 1, parent, undefined, resultCache)
)
.filter(isPlainObject);
if (allOfResults.length > 0) {
Object.assign(response, ...allOfResults);
}
}
// Check if the result is already cached
if (resultCache.has(schema)) {
return resultCache.get(schema);
@@ -307,45 +325,48 @@ const getExampleFromSchema = (
}
if (schema.anyOf !== undefined) {
Object.assign(
response,
getExampleFromSchema(
schema.anyOf[0],
options,
level + 1,
undefined,
undefined,
resultCache
)
);
const anyOfItem = schema.anyOf[0];
if (anyOfItem) {
// If anyOf[0] has allOf, process allOf items individually to merge object results
if (anyOfItem?.allOf !== undefined && Array.isArray(anyOfItem.allOf)) {
mergeAllOfIntoResponse(anyOfItem.allOf, response, anyOfItem);
} else {
const anyOfResult = getExampleFromSchema(
anyOfItem,
options,
level + 1,
undefined,
undefined,
resultCache
);
if (isPlainObject(anyOfResult)) {
Object.assign(response, anyOfResult);
}
}
}
} else if (schema.oneOf !== undefined) {
Object.assign(
response,
getExampleFromSchema(
schema.oneOf[0],
options,
level + 1,
undefined,
undefined,
resultCache
)
);
const oneOfItem = schema.oneOf[0];
if (oneOfItem) {
// If oneOf[0] has allOf, process allOf items individually to merge object results
if (oneOfItem?.allOf !== undefined && Array.isArray(oneOfItem.allOf)) {
mergeAllOfIntoResponse(oneOfItem.allOf, response, oneOfItem);
} else {
const oneOfResult = getExampleFromSchema(
oneOfItem,
options,
level + 1,
undefined,
undefined,
resultCache
);
if (isPlainObject(oneOfResult)) {
Object.assign(response, oneOfResult);
}
}
}
} else if (schema.allOf !== undefined) {
Object.assign(
response,
...schema.allOf
.map((item: Record<string, any>) =>
getExampleFromSchema(
item,
options,
level + 1,
schema,
undefined,
resultCache
)
)
.filter((item: any) => item !== undefined)
);
mergeAllOfIntoResponse(schema.allOf, response, schema);
}
return cache(schema, response);
+16 -10
View File
@@ -218,7 +218,10 @@ function getStatusCodeCategory(statusCode: number | string): number | string {
return category;
}
export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
export function getSchemaTitle(
schema: OpenAPIV3.SchemaObject,
options?: { ignoreAlternatives?: boolean }
): string {
// Otherwise try to infer a nice title
let type = 'any';
@@ -226,7 +229,7 @@ export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
type = `${schema.type} · enum`;
// check array AND schema.items as this is sometimes null despite what the type indicates
} else if (schema.type === 'array' && !!schema.items) {
type = `${getSchemaTitle(schema.items)}[]`;
type = `${getSchemaTitle(schema.items, options)}[]`;
} else if (Array.isArray(schema.type)) {
type = schema.type.join(' | ');
} else if (schema.type || schema.properties) {
@@ -242,14 +245,17 @@ export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
}
}
if ('anyOf' in schema) {
type = 'any of';
} else if ('oneOf' in schema) {
type = 'one of';
} else if ('allOf' in schema) {
type = 'all of';
} else if ('not' in schema) {
type = 'not';
// Skip alternative type labels if ignoreAlternatives is true (useful when rendering alternatives)
if (!options?.ignoreAlternatives) {
if ('anyOf' in schema) {
type = 'any of';
} else if ('oneOf' in schema) {
type = 'one of';
} else if ('allOf' in schema) {
type = 'all of';
} else if ('not' in schema) {
type = 'not';
}
}
return type;