mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Fix OpenAPI oneOf/allOf merge (#3844)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix OpenAPI oneOf/allOf merge
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user