Skip to content

Commit 54ed8fc

Browse files
committed
Merge branch 'main' into embed-frame-e2e
2 parents 9c9aad1 + 87d68ea commit 54ed8fc

File tree

8 files changed

+254
-73
lines changed

8 files changed

+254
-73
lines changed

.changeset/some-bags-wish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
---
4+
5+
Fix OpenAPI oneOf/allOf merge

packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export async function PageBodyBlankslate(props: {
4646
<Card
4747
key={child.id}
4848
leadingIcon={icon}
49-
title={child.title}
49+
title={child.linkTitle || child.title}
5050
href={resolved.href}
5151
insights={{
5252
type: 'link_click',
@@ -62,7 +62,14 @@ export async function PageBodyBlankslate(props: {
6262
pages: context.revision.pages,
6363
page: child,
6464
});
65-
return <Card key={child.id} title={child.title} leadingIcon={icon} href={href} />;
65+
return (
66+
<Card
67+
key={child.id}
68+
title={child.linkTitle || child.title}
69+
leadingIcon={icon}
70+
href={href}
71+
/>
72+
);
6673
})
6774
);
6875

packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export async function PageFooterNavigation(props: {
4343
<NavigationCard
4444
icon="chevron-left"
4545
label={t(language, 'previous_page')}
46-
title={previous.title}
46+
title={previous.linkTitle || previous.title}
4747
href={previousHref}
4848
insights={{
4949
type: 'link_click',
@@ -62,7 +62,7 @@ export async function PageFooterNavigation(props: {
6262
<NavigationCard
6363
icon="chevron-right"
6464
label={t(language, 'next_page')}
65-
title={next.title}
65+
title={next.linkTitle || next.title}
6666
href={nextHref}
6767
insights={{
6868
type: 'link_click',

packages/react-openapi/src/OpenAPISchema.tsx

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ export function OpenAPISchemaPresentation(props: {
382382
<div id={id} className="openapi-schema-presentation">
383383
<OpenAPISchemaName
384384
schema={schema}
385-
type={getSchemaTitle(schema)}
385+
type={getSchemaTitle(schema, { ignoreAlternatives: !propertyName })}
386386
propertyName={propertyName}
387387
isDiscriminatorProperty={isDiscriminatorProperty}
388388
required={required}
@@ -688,34 +688,90 @@ function flattenAlternatives(
688688
): OpenAPIV3.SchemaObject[] {
689689
// Get the parent schema's required fields from the most recent ancestor
690690
const latestAncestor = Array.from(ancestors).pop();
691+
const result: OpenAPIV3.SchemaObject[] = [];
691692

692-
return schemasOrRefs.reduce<OpenAPIV3.SchemaObject[]>((acc, schemaOrRef) => {
693+
for (const schemaOrRef of schemasOrRefs) {
693694
if (checkIsReference(schemaOrRef)) {
694-
return acc;
695+
continue;
695696
}
696697

697-
if (schemaOrRef[alternativeType] && !ancestors.has(schemaOrRef)) {
698-
const alternatives = getSchemaAlternatives(schemaOrRef, ancestors);
699-
if (alternatives?.schemas) {
700-
acc.push(
701-
...alternatives.schemas.map((schema) => ({
702-
...schema,
703-
required: mergeRequiredFields(schema, latestAncestor),
704-
}))
705-
);
698+
const flattened = flattenSchema(schemaOrRef, alternativeType, ancestors, latestAncestor);
699+
700+
if (flattened) {
701+
result.push(...flattened);
702+
}
703+
}
704+
705+
return result;
706+
}
707+
708+
/**
709+
* Flatten a schema that is an alternative of another schema.
710+
*/
711+
function flattenSchema(
712+
schema: OpenAPIV3.SchemaObject,
713+
alternativeType: AlternativeType,
714+
ancestors: Set<OpenAPIV3.SchemaObject>,
715+
latestAncestor: OpenAPIV3.SchemaObject | undefined
716+
): OpenAPIV3.SchemaObject[] {
717+
if (schema[alternativeType] && !ancestors.has(schema)) {
718+
const alternatives = getSchemaAlternatives(schema, ancestors);
719+
if (alternatives?.schemas) {
720+
return alternatives.schemas.map((s) => {
721+
const required = mergeRequiredFields(s, latestAncestor);
722+
return {
723+
...s,
724+
...(required ? { required } : {}),
725+
};
726+
});
727+
}
728+
729+
const required = mergeRequiredFields(schema, latestAncestor);
730+
return [{ ...schema, ...(required ? { required } : {}) }];
731+
}
732+
733+
// if a schema has allOf that can be safely merged, merge it
734+
if (
735+
(alternativeType === 'oneOf' || alternativeType === 'anyOf') &&
736+
schema.allOf &&
737+
Array.isArray(schema.allOf) &&
738+
!ancestors.has(schema)
739+
) {
740+
const allOfSchemas = schema.allOf.filter(
741+
(s): s is OpenAPIV3.SchemaObject => !checkIsReference(s)
742+
);
743+
744+
if (allOfSchemas.length > 0) {
745+
const merged = mergeAlternatives('allOf', allOfSchemas);
746+
if (merged && merged.length > 0) {
747+
// Only merge if all schemas were successfully merged into one (safe to merge)
748+
if (merged.length === 1) {
749+
return merged.map((s) => {
750+
const required = mergeRequiredFields(s, latestAncestor);
751+
const result: OpenAPIV3.SchemaObject = {
752+
...s,
753+
...(required ? { required } : {}),
754+
};
755+
756+
if (schema.title && !s.title) {
757+
result.title = schema.title;
758+
}
759+
760+
return result;
761+
});
762+
}
706763
}
707-
return acc;
708764
}
765+
}
709766

710-
// For direct schemas, handle required fields
711-
const schema = {
712-
...schemaOrRef,
713-
required: mergeRequiredFields(schemaOrRef, latestAncestor),
714-
};
767+
const required = mergeRequiredFields(schema, latestAncestor);
715768

716-
acc.push(schema);
717-
return acc;
718-
}, []);
769+
return [
770+
{
771+
...schema,
772+
...(required ? { required } : {}),
773+
},
774+
];
719775
}
720776

721777
/**

packages/react-openapi/src/contentTypeChecks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ export function isFormData(contentType?: string): boolean {
3434
return !!contentType && contentType.toLowerCase().includes('multipart/form-data');
3535
}
3636

37-
export function isPlainObject(value: unknown): boolean {
37+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
3838
return typeof value === 'object' && value !== null && !Array.isArray(value);
3939
}

packages/react-openapi/src/generateSchemaExample.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,4 +1037,90 @@ describe('generateSchemaExample', () => {
10371037
})
10381038
).toBeUndefined();
10391039
});
1040+
1041+
it('merges object properties from oneOf -> allOf', () => {
1042+
const schema = {
1043+
type: 'object',
1044+
properties: {
1045+
discriminator: {
1046+
type: 'string',
1047+
},
1048+
},
1049+
oneOf: [
1050+
{
1051+
allOf: [
1052+
{
1053+
type: 'object',
1054+
properties: {
1055+
bar: {
1056+
type: 'string',
1057+
},
1058+
},
1059+
},
1060+
{
1061+
type: 'object',
1062+
properties: {
1063+
baz: {
1064+
type: 'number',
1065+
},
1066+
},
1067+
},
1068+
{
1069+
type: 'string', // This will return a string, but should be ignored
1070+
},
1071+
],
1072+
},
1073+
],
1074+
} satisfies OpenAPIV3.SchemaObject;
1075+
1076+
const result = generateSchemaExample(schema);
1077+
1078+
expect(result).toBeDefined();
1079+
expect(result).toHaveProperty('discriminator');
1080+
expect(result).toHaveProperty('bar');
1081+
expect(result).toHaveProperty('baz');
1082+
});
1083+
1084+
it('merges object properties from anyOf -> allOf', () => {
1085+
const schema = {
1086+
type: 'object',
1087+
properties: {
1088+
discriminator: {
1089+
type: 'string',
1090+
},
1091+
},
1092+
anyOf: [
1093+
{
1094+
allOf: [
1095+
{
1096+
type: 'object',
1097+
properties: {
1098+
bar: {
1099+
type: 'string',
1100+
},
1101+
},
1102+
},
1103+
{
1104+
type: 'object',
1105+
properties: {
1106+
baz: {
1107+
type: 'number',
1108+
},
1109+
},
1110+
},
1111+
{
1112+
type: 'string', // This will return a string, but should be ignored
1113+
},
1114+
],
1115+
},
1116+
],
1117+
} satisfies OpenAPIV3.SchemaObject;
1118+
1119+
const result = generateSchemaExample(schema);
1120+
1121+
expect(result).toBeDefined();
1122+
expect(result).toHaveProperty('discriminator');
1123+
expect(result).toHaveProperty('bar');
1124+
expect(result).toHaveProperty('baz');
1125+
});
10401126
});

packages/react-openapi/src/generateSchemaExample.ts

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
2+
import { isPlainObject } from './contentTypeChecks';
23
import { checkIsReference } from './utils';
34

45
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
@@ -147,6 +148,23 @@ const getExampleFromSchema = (
147148
return result;
148149
}
149150

151+
// Process allOf items and merge object results into the response
152+
function mergeAllOfIntoResponse(
153+
allOfItems: Record<string, unknown>[],
154+
response: Record<string, unknown>,
155+
parent: Record<string, unknown> | undefined
156+
): void {
157+
const allOfResults = allOfItems
158+
.map((item: Record<string, unknown>) =>
159+
getExampleFromSchema(item, options, level + 1, parent, undefined, resultCache)
160+
)
161+
.filter(isPlainObject);
162+
163+
if (allOfResults.length > 0) {
164+
Object.assign(response, ...allOfResults);
165+
}
166+
}
167+
150168
// Check if the result is already cached
151169
if (resultCache.has(schema)) {
152170
return resultCache.get(schema);
@@ -307,45 +325,48 @@ const getExampleFromSchema = (
307325
}
308326

309327
if (schema.anyOf !== undefined) {
310-
Object.assign(
311-
response,
312-
getExampleFromSchema(
313-
schema.anyOf[0],
314-
options,
315-
level + 1,
316-
undefined,
317-
undefined,
318-
resultCache
319-
)
320-
);
328+
const anyOfItem = schema.anyOf[0];
329+
330+
if (anyOfItem) {
331+
// If anyOf[0] has allOf, process allOf items individually to merge object results
332+
if (anyOfItem?.allOf !== undefined && Array.isArray(anyOfItem.allOf)) {
333+
mergeAllOfIntoResponse(anyOfItem.allOf, response, anyOfItem);
334+
} else {
335+
const anyOfResult = getExampleFromSchema(
336+
anyOfItem,
337+
options,
338+
level + 1,
339+
undefined,
340+
undefined,
341+
resultCache
342+
);
343+
if (isPlainObject(anyOfResult)) {
344+
Object.assign(response, anyOfResult);
345+
}
346+
}
347+
}
321348
} else if (schema.oneOf !== undefined) {
322-
Object.assign(
323-
response,
324-
getExampleFromSchema(
325-
schema.oneOf[0],
326-
options,
327-
level + 1,
328-
undefined,
329-
undefined,
330-
resultCache
331-
)
332-
);
349+
const oneOfItem = schema.oneOf[0];
350+
if (oneOfItem) {
351+
// If oneOf[0] has allOf, process allOf items individually to merge object results
352+
if (oneOfItem?.allOf !== undefined && Array.isArray(oneOfItem.allOf)) {
353+
mergeAllOfIntoResponse(oneOfItem.allOf, response, oneOfItem);
354+
} else {
355+
const oneOfResult = getExampleFromSchema(
356+
oneOfItem,
357+
options,
358+
level + 1,
359+
undefined,
360+
undefined,
361+
resultCache
362+
);
363+
if (isPlainObject(oneOfResult)) {
364+
Object.assign(response, oneOfResult);
365+
}
366+
}
367+
}
333368
} else if (schema.allOf !== undefined) {
334-
Object.assign(
335-
response,
336-
...schema.allOf
337-
.map((item: Record<string, any>) =>
338-
getExampleFromSchema(
339-
item,
340-
options,
341-
level + 1,
342-
schema,
343-
undefined,
344-
resultCache
345-
)
346-
)
347-
.filter((item: any) => item !== undefined)
348-
);
369+
mergeAllOfIntoResponse(schema.allOf, response, schema);
349370
}
350371

351372
return cache(schema, response);

0 commit comments

Comments
 (0)