diff --git a/.changeset/some-bags-wish.md b/.changeset/some-bags-wish.md new file mode 100644 index 000000000..9a8a8b785 --- /dev/null +++ b/.changeset/some-bags-wish.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Fix OpenAPI oneOf/allOf merge diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index 7f9d0217a..e9aff7df3 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -382,7 +382,7 @@ export function OpenAPISchemaPresentation(props: {
((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, + 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 } : {}), + }, + ]; } /** diff --git a/packages/react-openapi/src/contentTypeChecks.ts b/packages/react-openapi/src/contentTypeChecks.ts index e1b5debda..8eb9a8a3c 100644 --- a/packages/react-openapi/src/contentTypeChecks.ts +++ b/packages/react-openapi/src/contentTypeChecks.ts @@ -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 { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/react-openapi/src/generateSchemaExample.test.ts b/packages/react-openapi/src/generateSchemaExample.test.ts index 682c5f2f3..1c8e3f0de 100644 --- a/packages/react-openapi/src/generateSchemaExample.test.ts +++ b/packages/react-openapi/src/generateSchemaExample.test.ts @@ -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'); + }); }); diff --git a/packages/react-openapi/src/generateSchemaExample.ts b/packages/react-openapi/src/generateSchemaExample.ts index 595f723d9..447344b44 100644 --- a/packages/react-openapi/src/generateSchemaExample.ts +++ b/packages/react-openapi/src/generateSchemaExample.ts @@ -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[], + response: Record, + parent: Record | undefined + ): void { + const allOfResults = allOfItems + .map((item: Record) => + 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) => - getExampleFromSchema( - item, - options, - level + 1, - schema, - undefined, - resultCache - ) - ) - .filter((item: any) => item !== undefined) - ); + mergeAllOfIntoResponse(schema.allOf, response, schema); } return cache(schema, response); diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index f6d0afad5..97228e462 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -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;