mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Handle circular oneOf with discriminator and allOf in OpenAPI schemas (#4070)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user