Handle circular oneOf with discriminator and allOf in OpenAPI schemas (#4070)

This commit is contained in:
Nolann B.
2026-06-16 13:34:17 +02:00
committed by GitHub
parent 251630c49f
commit 88b94b5d36
4 changed files with 344 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { type OpenAPIV3, parseOpenAPI } from '@gitbook/openapi-parser';
import { getSchemaAlternatives, getSchemaProperties } from './OpenAPISchema';
import { dereferenceFilesystem } from './dereference';
describe('getSchemaAlternatives', () => {
it('should flatten oneOf', () => {
@@ -681,6 +682,209 @@ describe('getSchemaAlternatives', () => {
expect(result?.schemas[0]?.required).toContain('labelArgbColor');
});
});
describe('circular oneOf with discriminator and allOf', () => {
it('should handle variants that reference the parent via allOf', () => {
const pet: OpenAPIV3.SchemaObject = {
type: 'object',
description: 'A pet in the store',
discriminator: {
propertyName: 'petType',
mapping: {
dog: '#/components/schemas/Dog',
cat: '#/components/schemas/Cat',
},
},
oneOf: [],
properties: {
name: { type: 'string' },
petType: { type: 'string' },
},
required: ['petType'],
};
const dog: OpenAPIV3.SchemaObject = {
title: 'Dog',
allOf: [pet, { type: 'object', properties: { barkVolume: { type: 'number' } } }],
};
const cat: OpenAPIV3.SchemaObject = {
title: 'Cat',
allOf: [pet],
properties: { huntingSkill: { type: 'string' } },
};
pet.oneOf = [dog, cat];
const result = getSchemaAlternatives(pet);
expect(result?.type).toBe('oneOf');
expect(result?.schemas).toHaveLength(2);
const dogVariant = result?.schemas[0];
expect(dogVariant?.title).toBe('Dog');
expect(dogVariant?.properties).toHaveProperty('name');
expect(dogVariant?.properties).toHaveProperty('petType');
expect(dogVariant?.properties).toHaveProperty('barkVolume');
expect(dogVariant).not.toHaveProperty('oneOf');
expect(dogVariant).not.toHaveProperty('discriminator');
expect(dogVariant).not.toHaveProperty('description');
const catVariant = result?.schemas[1];
expect(catVariant?.title).toBe('Cat');
expect(catVariant?.properties).toHaveProperty('name');
expect(catVariant?.properties).toHaveProperty('petType');
expect(catVariant?.properties).toHaveProperty('huntingSkill');
// Original schema must not be mutated
expect(Object.keys(pet.properties ?? {})).toHaveLength(2);
expect(pet.properties).not.toHaveProperty('barkVolume');
expect(pet.properties).not.toHaveProperty('huntingSkill');
});
it('should handle dereferenced copies (different object, shared property refs)', () => {
// After @scalar/openapi-parser dereference, $ref entries become new objects
// with shallow-copied properties from the original (not the same JS reference).
const pet: OpenAPIV3.SchemaObject = {
type: 'object',
description: 'A pet in the store',
discriminator: {
propertyName: 'petType',
mapping: {
dog: '#/components/schemas/Dog',
cat: '#/components/schemas/Cat',
},
},
oneOf: [],
properties: {
name: { type: 'string' },
petType: { type: 'string' },
},
required: ['petType'],
};
// Simulate dereference: $ref is replaced with a NEW object that has
// the same property values (shared references) as the original.
const petCopyForDog = { ...pet };
const petCopyForCat = { ...pet };
const dog: OpenAPIV3.SchemaObject = {
title: 'Dog',
allOf: [
petCopyForDog,
{ type: 'object', properties: { barkVolume: { type: 'number' } } },
],
};
const cat: OpenAPIV3.SchemaObject = {
title: 'Cat',
allOf: [petCopyForCat],
properties: { huntingSkill: { type: 'string' } },
};
pet.oneOf = [dog, cat];
const result = getSchemaAlternatives(pet);
expect(result?.type).toBe('oneOf');
expect(result?.schemas).toHaveLength(2);
const dogVariant = result?.schemas[0];
expect(dogVariant?.title).toBe('Dog');
expect(dogVariant?.properties).toHaveProperty('name');
expect(dogVariant?.properties).toHaveProperty('petType');
expect(dogVariant?.properties).toHaveProperty('barkVolume');
expect(dogVariant).not.toHaveProperty('oneOf');
expect(dogVariant).not.toHaveProperty('discriminator');
expect(dogVariant).not.toHaveProperty('description');
const catVariant = result?.schemas[1];
expect(catVariant?.title).toBe('Cat');
expect(catVariant?.properties).toHaveProperty('name');
expect(catVariant?.properties).toHaveProperty('petType');
expect(catVariant?.properties).toHaveProperty('huntingSkill');
expect(catVariant).not.toHaveProperty('oneOf');
expect(catVariant).not.toHaveProperty('discriminator');
expect(catVariant).not.toHaveProperty('description');
});
});
describe('integration: parse + dereference + getSchemaAlternatives', () => {
it('should resolve polymorphic oneOf variants from a real spec', async () => {
const spec = JSON.stringify({
openapi: '3.0.1',
info: { title: 'PetStore', version: '1.0' },
paths: {},
components: {
schemas: {
Pet: {
type: 'object',
description: 'A pet in the store',
discriminator: {
propertyName: 'petType',
mapping: {
dog: '#/components/schemas/Dog',
cat: '#/components/schemas/Cat',
},
},
oneOf: [
{ $ref: '#/components/schemas/Dog' },
{ $ref: '#/components/schemas/Cat' },
],
properties: {
name: { type: 'string' },
petType: { type: 'string' },
},
required: ['petType'],
},
Dog: {
allOf: [
{ $ref: '#/components/schemas/Pet' },
{
type: 'object',
properties: { barkVolume: { type: 'number' } },
},
],
},
Cat: {
allOf: [{ $ref: '#/components/schemas/Pet' }],
properties: { huntingSkill: { type: 'string' } },
},
},
},
});
const { filesystem } = await parseOpenAPI({
value: spec,
rootURL: 'memory://spec.json',
});
const doc = await dereferenceFilesystem(filesystem);
const pet = doc.components?.schemas?.Pet as OpenAPIV3.SchemaObject;
const result = getSchemaAlternatives(pet);
expect(result?.type).toBe('oneOf');
expect(result?.schemas).toHaveLength(2);
const dogVariant = result?.schemas[0];
expect(dogVariant?.title).toBe('Dog');
expect(dogVariant?.properties).toHaveProperty('name');
expect(dogVariant?.properties).toHaveProperty('petType');
expect(dogVariant?.properties).toHaveProperty('barkVolume');
expect(dogVariant).not.toHaveProperty('oneOf');
expect(dogVariant).not.toHaveProperty('discriminator');
expect(dogVariant).not.toHaveProperty('description');
const catVariant = result?.schemas[1];
expect(catVariant?.title).toBe('Cat');
expect(catVariant?.properties).toHaveProperty('name');
expect(catVariant?.properties).toHaveProperty('petType');
expect(catVariant?.properties).toHaveProperty('huntingSkill');
expect(catVariant).not.toHaveProperty('oneOf');
expect(catVariant).not.toHaveProperty('discriminator');
expect(catVariant).not.toHaveProperty('description');
});
});
});
describe('getSchemaProperties', () => {
+100 -3
View File
@@ -453,7 +453,10 @@ function OpenAPISchemaAlternativeSeparator(props: {
/**
* Render a circular reference to a schema.
*/
function OpenAPISchemaCircularRef(props: { id: string; schema: OpenAPIV3.SchemaObject }) {
function OpenAPISchemaCircularRef(props: {
id: string;
schema: OpenAPIV3.SchemaObject;
}) {
const { id, schema } = props;
return (
@@ -1095,7 +1098,7 @@ function flattenSchema(
return [{ ...schema, ...(required ? { required } : {}) }];
}
// if a schema has allOf that can be safely merged, merge it
// If a schema has allOf that can be safely merged, merge it.
if (
(alternativeType === 'oneOf' || alternativeType === 'anyOf') &&
schema.allOf &&
@@ -1107,6 +1110,11 @@ function flattenSchema(
);
if (allOfSchemas.length > 0) {
// Circular allOf: a variant references its parent (e.g. Dog allOf: [Pet, ...])
if (allOfSchemas.some((s) => isAncestorOrCopy(s, ancestors))) {
return flattenCircularAllOf(schema, allOfSchemas, ancestors, latestAncestor);
}
const merged = mergeAlternatives('allOf', allOfSchemas);
if (merged && merged.length > 0) {
// Only merge if all schemas were successfully merged into one (safe to merge)
@@ -1135,6 +1143,95 @@ function flattenSchema(
];
}
/**
* Flatten a circular allOf by stripping ancestor fields and merging the rest.
*/
function flattenCircularAllOf(
schema: OpenAPIV3.SchemaObject,
allOfSchemas: OpenAPIV3.SchemaObject[],
ancestors: Set<OpenAPIV3.SchemaObject>,
latestAncestor: OpenAPIV3.SchemaObject | undefined
): OpenAPIV3.SchemaObject[] {
const cleanSchemas = allOfSchemas.map((s) =>
isAncestorOrCopy(s, ancestors) ? stripAncestorFields(s, ancestors) : s
);
const { allOf: _, oneOf: _1, anyOf: _2, discriminator: _3, ...ownProps } = schema;
let merged = mergeSchemas(cleanSchemas);
merged = mergeTwoSchemas(merged, ownProps);
const required = mergeRequiredFields(merged, latestAncestor);
return [
{
...merged,
...(required ? { required } : {}),
...(schema.title ? { title: schema.title } : {}),
},
];
}
/**
* Check if a schema is an ancestor or a structurally matching copy of one.
*/
function isAncestorOrCopy(
schema: OpenAPIV3.SchemaObject,
ancestors: Set<OpenAPIV3.SchemaObject>
): boolean {
if (ancestors.has(schema)) {
return true;
}
const discriminatorName = schema.discriminator?.propertyName;
if (!discriminatorName) {
return false;
}
for (const ancestor of Array.from(ancestors)) {
if (ancestor.discriminator?.propertyName === discriminatorName) {
return true;
}
}
return false;
}
const polymorphicFields = new Set([
'oneOf',
'anyOf',
'discriminator',
'description',
'x-gitbook-description-html',
]);
/**
* Strip polymorphic fields from an ancestor schema, preserving non-circular allOf composition.
*/
function stripAncestorFields(
schema: OpenAPIV3.SchemaObject,
ancestors: Set<OpenAPIV3.SchemaObject>
): OpenAPIV3.SchemaObject {
let base: OpenAPIV3.SchemaObject = schema;
// Merge non-circular allOf entries so composition properties aren't lost
if (Array.isArray(schema.allOf)) {
const safeAllOf = schema.allOf.filter(
(s): s is OpenAPIV3.SchemaObject =>
!checkIsReference(s) && !isAncestorOrCopy(s, ancestors)
);
if (safeAllOf.length > 0) {
base = mergeSchemas([schema, ...safeAllOf]);
}
}
const clean = Object.fromEntries(
Object.entries(base).filter(([key]) => !polymorphicFields.has(key) && key !== 'allOf')
) as OpenAPIV3.SchemaObject;
if (clean.properties) {
clean.properties = { ...clean.properties };
}
if (Array.isArray(clean.required)) {
clean.required = [...clean.required];
}
return clean;
}
/**
* Merge two schemas by combining their properties and required fields.
* Later schema properties override earlier ones.
@@ -1182,7 +1279,7 @@ function mergeSchemas(schemas: OpenAPIV3.SchemaObject[]): OpenAPIV3.SchemaObject
return firstSchema;
}
// Start with first schema and merge the rest into it
return schemas.reduce((acc, schema) => mergeTwoSchemas(acc, schema), firstSchema);
return schemas.slice(1).reduce((acc, schema) => mergeTwoSchemas(acc, schema), firstSchema);
}
/**
+34 -1
View File
@@ -1,4 +1,9 @@
import { type Filesystem, type OpenAPIV3xDocument, dereference } from '@gitbook/openapi-parser';
import {
type Filesystem,
type OpenAPIV3,
type OpenAPIV3xDocument,
dereference,
} from '@gitbook/openapi-parser';
const dereferenceCache = new WeakMap<Filesystem, Promise<OpenAPIV3xDocument>>();
@@ -19,6 +24,10 @@ export function dereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3
* Dereference an OpenAPI schema.
*/
async function baseDereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3xDocument> {
// Set default titles BEFORE dereferencing so they propagate through $ref resolution.
// This is idempotent and only adds titles to schemas that don't already have one.
setDefaultSchemaTitles(filesystem);
const result = await dereference(filesystem);
if (!result.schema) {
@@ -27,3 +36,27 @@ async function baseDereferenceFilesystem(filesystem: Filesystem): Promise<OpenAP
return result.schema as OpenAPIV3xDocument;
}
/**
* Default schema titles to their component name for discriminator value resolution.
* Must run before dereference so titles propagate through $ref resolution.
*/
function setDefaultSchemaTitles(filesystem: Filesystem): void {
const entrypoint = filesystem.find((f) => f.isEntrypoint);
const schemas = entrypoint?.specification?.components?.schemas as Record<
string,
OpenAPIV3.SchemaObject
>;
if (!schemas || typeof schemas !== 'object') {
return;
}
const entries = Object.entries(schemas);
for (const [name, schema] of entries) {
if (schema && typeof schema === 'object' && !schema.$ref && !schema.title) {
schema.title = name;
}
}
}