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;