Fix merge logic for allOf alternatives (#3515)

This commit is contained in:
Nolann B.
2025-10-06 14:37:51 +02:00
committed by GitHub
parent 3551d147e9
commit 754cc11e0b
3 changed files with 259 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix merge logic for allOf alternatives
@@ -184,4 +184,203 @@ describe('getSchemaAlternatives', () => {
],
});
});
describe('safe merging with allOf', () => {
it('should merge objects with safe extensions', () => {
expect(
getSchemaAlternatives({
allOf: [
{
type: 'object',
properties: {
name: {
type: 'string',
},
},
required: ['name'],
description: 'Base schema',
title: 'Base',
},
{
type: 'object',
properties: {
email: {
type: 'string',
},
},
required: ['email'],
description: 'Extended schema',
example: { email: 'test@example.com' },
deprecated: true,
},
],
})
).toEqual({
type: 'allOf',
schemas: [
{
type: 'object',
properties: {
name: {
type: 'string',
},
email: {
type: 'string',
},
},
required: ['name', 'email'],
description: 'Extended schema',
title: 'Base',
example: { email: 'test@example.com' },
deprecated: true,
},
],
});
});
it('should merge objects with vendor extensions', () => {
expect(
getSchemaAlternatives({
allOf: [
{
type: 'object',
properties: {
id: {
type: 'string',
},
},
'x-internal': true,
},
{
type: 'object',
properties: {
name: {
type: 'string',
},
},
'x-version': '1.0',
'x-internal': false,
},
],
})
).toEqual({
type: 'allOf',
schemas: [
{
type: 'object',
properties: {
id: {
type: 'string',
},
name: {
type: 'string',
},
},
required: [],
'x-internal': false,
'x-version': '1.0',
},
],
});
});
it('should merge objects with nullable', () => {
expect(
getSchemaAlternatives({
allOf: [
{
type: 'object',
properties: {
field1: {
type: 'string',
},
},
},
{
type: 'object',
properties: {
field2: {
type: 'string',
},
},
nullable: true,
},
],
})
).toEqual({
type: 'allOf',
schemas: [
{
type: 'object',
properties: {
field1: {
type: 'string',
},
field2: {
type: 'string',
},
},
required: [],
nullable: true,
},
],
});
});
it('should NOT merge objects with unsafe properties', () => {
expect(
getSchemaAlternatives({
allOf: [
{
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
{
type: 'object',
properties: {
value: {
type: 'string',
},
},
// oneOf is not a safe property to merge
oneOf: [
{
type: 'string',
},
],
},
],
})
).toEqual({
type: 'allOf',
schemas: [
{
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
{
type: 'object',
properties: {
value: {
type: 'string',
},
},
oneOf: [
{
type: 'string',
},
],
},
],
});
});
});
});
+55 -9
View File
@@ -540,9 +540,39 @@ export function getSchemaAlternatives(
};
}
// These extensions are safe to merge
const safeExtensions = [
'description',
'title',
'example',
'examples',
'default',
'readOnly',
'writeOnly',
'deprecated',
];
/**
* Determine if a schema is safe to merge based on its properties
*/
function isSafeToMerge(schema: OpenAPIV3.SchemaObject): boolean {
const keys = Object.keys(schema);
const coreProperties = ['type', 'properties', 'required', 'nullable'];
const coreKeys = keys.filter((key) => coreProperties.includes(key));
const unknownKeys = keys.filter(
(key) =>
!coreProperties.includes(key) && !safeExtensions.includes(key) && !key.startsWith('x-')
);
return coreKeys.length > 0 && unknownKeys.length === 0;
}
/**
* Merge alternatives of the same type into a single schema.
* - Merge string enums
* - Safely merge object schemas with compatible properties
*/
function mergeAlternatives(
alternativeType: AlternativeType,
@@ -590,24 +620,40 @@ function mergeAlternatives(
if (latest && latest.type === 'object' && schemaOrRef.type === 'object') {
const keys = Object.keys(schemaOrRef);
if (
keys.every((key) =>
['type', 'properties', 'required', 'nullable'].includes(key)
)
) {
if (isSafeToMerge(schemaOrRef)) {
const safeKeys = keys.filter((key) => safeExtensions.includes(key));
const vendorKeys = keys.filter((key) => key.startsWith('x-'));
latest.properties = {
...latest.properties,
...schemaOrRef.properties,
...(latest.properties || {}),
...(schemaOrRef.properties || {}),
};
latest.required = Array.from(
new Set([
...(Array.isArray(latest.required) ? latest.required : []),
...(Array.isArray(schemaOrRef.required)
...(latest.required && Array.isArray(latest.required)
? latest.required
: []),
...(schemaOrRef.required && Array.isArray(schemaOrRef.required)
? schemaOrRef.required
: []),
])
);
latest.nullable = latest.nullable || schemaOrRef.nullable;
// Preserve safe extensions and vendor extensions
// Always overwrite (last schema has priority)
[...vendorKeys, ...safeKeys].forEach((key) => {
if (
typeof latest[key] === 'object' &&
typeof schemaOrRef[key] === 'object'
) {
latest[key] = { ...latest[key], ...schemaOrRef[key] };
} else {
latest[key] = schemaOrRef[key];
}
});
return acc;
}
}