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"> <div id={id} className="openapi-schema-presentation">
<OpenAPISchemaName <OpenAPISchemaName
schema={schema} schema={schema}
type={getSchemaTitle(schema)} type={getSchemaTitle(schema, { ignoreAlternatives: !propertyName })}
propertyName={propertyName} propertyName={propertyName}
isDiscriminatorProperty={isDiscriminatorProperty} isDiscriminatorProperty={isDiscriminatorProperty}
required={required} required={required}
@@ -688,34 +688,90 @@ function flattenAlternatives(
): OpenAPIV3.SchemaObject[] { ): OpenAPIV3.SchemaObject[] {
// Get the parent schema's required fields from the most recent ancestor // Get the parent schema's required fields from the most recent ancestor
const latestAncestor = Array.from(ancestors).pop(); 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)) { if (checkIsReference(schemaOrRef)) {
return acc; continue;
} }
if (schemaOrRef[alternativeType] && !ancestors.has(schemaOrRef)) { const flattened = flattenSchema(schemaOrRef, alternativeType, ancestors, latestAncestor);
const alternatives = getSchemaAlternatives(schemaOrRef, ancestors);
if (alternatives?.schemas) { if (flattened) {
acc.push( result.push(...flattened);
...alternatives.schemas.map((schema) => ({ }
...schema, }
required: mergeRequiredFields(schema, latestAncestor),
})) 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 required = mergeRequiredFields(schema, latestAncestor);
const schema = {
...schemaOrRef,
required: mergeRequiredFields(schemaOrRef, latestAncestor),
};
acc.push(schema); return [
return acc; {
}, []); ...schema,
...(required ? { required } : {}),
},
];
} }
/** /**
@@ -34,6 +34,6 @@ export function isFormData(contentType?: string): boolean {
return !!contentType && contentType.toLowerCase().includes('multipart/form-data'); 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); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
@@ -1037,4 +1037,90 @@ describe('generateSchemaExample', () => {
}) })
).toBeUndefined(); ).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 type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { isPlainObject } from './contentTypeChecks';
import { checkIsReference } from './utils'; import { checkIsReference } from './utils';
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
@@ -147,6 +148,23 @@ const getExampleFromSchema = (
return result; 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 // Check if the result is already cached
if (resultCache.has(schema)) { if (resultCache.has(schema)) {
return resultCache.get(schema); return resultCache.get(schema);
@@ -307,45 +325,48 @@ const getExampleFromSchema = (
} }
if (schema.anyOf !== undefined) { if (schema.anyOf !== undefined) {
Object.assign( const anyOfItem = schema.anyOf[0];
response,
getExampleFromSchema( if (anyOfItem) {
schema.anyOf[0], // If anyOf[0] has allOf, process allOf items individually to merge object results
options, if (anyOfItem?.allOf !== undefined && Array.isArray(anyOfItem.allOf)) {
level + 1, mergeAllOfIntoResponse(anyOfItem.allOf, response, anyOfItem);
undefined, } else {
undefined, const anyOfResult = getExampleFromSchema(
resultCache anyOfItem,
) options,
); level + 1,
undefined,
undefined,
resultCache
);
if (isPlainObject(anyOfResult)) {
Object.assign(response, anyOfResult);
}
}
}
} else if (schema.oneOf !== undefined) { } else if (schema.oneOf !== undefined) {
Object.assign( const oneOfItem = schema.oneOf[0];
response, if (oneOfItem) {
getExampleFromSchema( // If oneOf[0] has allOf, process allOf items individually to merge object results
schema.oneOf[0], if (oneOfItem?.allOf !== undefined && Array.isArray(oneOfItem.allOf)) {
options, mergeAllOfIntoResponse(oneOfItem.allOf, response, oneOfItem);
level + 1, } else {
undefined, const oneOfResult = getExampleFromSchema(
undefined, oneOfItem,
resultCache options,
) level + 1,
); undefined,
undefined,
resultCache
);
if (isPlainObject(oneOfResult)) {
Object.assign(response, oneOfResult);
}
}
}
} else if (schema.allOf !== undefined) { } else if (schema.allOf !== undefined) {
Object.assign( mergeAllOfIntoResponse(schema.allOf, response, schema);
response,
...schema.allOf
.map((item: Record<string, any>) =>
getExampleFromSchema(
item,
options,
level + 1,
schema,
undefined,
resultCache
)
)
.filter((item: any) => item !== undefined)
);
} }
return cache(schema, response); return cache(schema, response);
+16 -10
View File
@@ -218,7 +218,10 @@ function getStatusCodeCategory(statusCode: number | string): number | string {
return category; 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 // Otherwise try to infer a nice title
let type = 'any'; let type = 'any';
@@ -226,7 +229,7 @@ export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
type = `${schema.type} · enum`; type = `${schema.type} · enum`;
// check array AND schema.items as this is sometimes null despite what the type indicates // check array AND schema.items as this is sometimes null despite what the type indicates
} else if (schema.type === 'array' && !!schema.items) { } else if (schema.type === 'array' && !!schema.items) {
type = `${getSchemaTitle(schema.items)}[]`; type = `${getSchemaTitle(schema.items, options)}[]`;
} else if (Array.isArray(schema.type)) { } else if (Array.isArray(schema.type)) {
type = schema.type.join(' | '); type = schema.type.join(' | ');
} else if (schema.type || schema.properties) { } else if (schema.type || schema.properties) {
@@ -242,14 +245,17 @@ export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
} }
} }
if ('anyOf' in schema) { // Skip alternative type labels if ignoreAlternatives is true (useful when rendering alternatives)
type = 'any of'; if (!options?.ignoreAlternatives) {
} else if ('oneOf' in schema) { if ('anyOf' in schema) {
type = 'one of'; type = 'any of';
} else if ('allOf' in schema) { } else if ('oneOf' in schema) {
type = 'all of'; type = 'one of';
} else if ('not' in schema) { } else if ('allOf' in schema) {
type = 'not'; type = 'all of';
} else if ('not' in schema) {
type = 'not';
}
} }
return type; return type;