mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 23:55:20 +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">
|
<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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user