mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 19:06:31 +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 { 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 { getSchemaAlternatives, getSchemaProperties } from './OpenAPISchema';
|
||||||
|
import { dereferenceFilesystem } from './dereference';
|
||||||
|
|
||||||
describe('getSchemaAlternatives', () => {
|
describe('getSchemaAlternatives', () => {
|
||||||
it('should flatten oneOf', () => {
|
it('should flatten oneOf', () => {
|
||||||
@@ -681,6 +682,209 @@ describe('getSchemaAlternatives', () => {
|
|||||||
expect(result?.schemas[0]?.required).toContain('labelArgbColor');
|
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', () => {
|
describe('getSchemaProperties', () => {
|
||||||
|
|||||||
@@ -453,7 +453,10 @@ function OpenAPISchemaAlternativeSeparator(props: {
|
|||||||
/**
|
/**
|
||||||
* Render a circular reference to a schema.
|
* 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;
|
const { id, schema } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1095,7 +1098,7 @@ function flattenSchema(
|
|||||||
return [{ ...schema, ...(required ? { required } : {}) }];
|
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 (
|
if (
|
||||||
(alternativeType === 'oneOf' || alternativeType === 'anyOf') &&
|
(alternativeType === 'oneOf' || alternativeType === 'anyOf') &&
|
||||||
schema.allOf &&
|
schema.allOf &&
|
||||||
@@ -1107,6 +1110,11 @@ function flattenSchema(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (allOfSchemas.length > 0) {
|
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);
|
const merged = mergeAlternatives('allOf', allOfSchemas);
|
||||||
if (merged && merged.length > 0) {
|
if (merged && merged.length > 0) {
|
||||||
// Only merge if all schemas were successfully merged into one (safe to merge)
|
// 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.
|
* Merge two schemas by combining their properties and required fields.
|
||||||
* Later schema properties override earlier ones.
|
* Later schema properties override earlier ones.
|
||||||
@@ -1182,7 +1279,7 @@ function mergeSchemas(schemas: OpenAPIV3.SchemaObject[]): OpenAPIV3.SchemaObject
|
|||||||
return firstSchema;
|
return firstSchema;
|
||||||
}
|
}
|
||||||
// Start with first schema and merge the rest into it
|
// 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>>();
|
const dereferenceCache = new WeakMap<Filesystem, Promise<OpenAPIV3xDocument>>();
|
||||||
|
|
||||||
@@ -19,6 +24,10 @@ export function dereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3
|
|||||||
* Dereference an OpenAPI schema.
|
* Dereference an OpenAPI schema.
|
||||||
*/
|
*/
|
||||||
async function baseDereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3xDocument> {
|
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);
|
const result = await dereference(filesystem);
|
||||||
|
|
||||||
if (!result.schema) {
|
if (!result.schema) {
|
||||||
@@ -27,3 +36,27 @@ async function baseDereferenceFilesystem(filesystem: Filesystem): Promise<OpenAP
|
|||||||
|
|
||||||
return result.schema as OpenAPIV3xDocument;
|
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