diff --git a/.changeset/tiny-items-talk.md b/.changeset/tiny-items-talk.md new file mode 100644 index 000000000..30c196306 --- /dev/null +++ b/.changeset/tiny-items-talk.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-openapi": patch +--- + +Handle circular oneOf with discriminator and allOf in OpenAPI schemas diff --git a/packages/react-openapi/src/OpenAPISchema.test.ts b/packages/react-openapi/src/OpenAPISchema.test.ts index 4526c9b37..dacd6a6ed 100644 --- a/packages/react-openapi/src/OpenAPISchema.test.ts +++ b/packages/react-openapi/src/OpenAPISchema.test.ts @@ -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', () => { diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index 0b6c4a83b..fee855a38 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -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, + 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 +): 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 { + 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); } /** diff --git a/packages/react-openapi/src/dereference.ts b/packages/react-openapi/src/dereference.ts index dc772f4cd..350206eeb 100644 --- a/packages/react-openapi/src/dereference.ts +++ b/packages/react-openapi/src/dereference.ts @@ -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>(); @@ -19,6 +24,10 @@ export function dereferenceFilesystem(filesystem: Filesystem): Promise { + // 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 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; + } + } +}