import { describe, expect, it } from 'bun:test'; import { type OpenAPIV3, parseOpenAPI } from '@gitbook/openapi-parser'; import { dereferenceFilesystem } from './dereference'; import { getSchemaAlternatives, getSchemaProperties } from './OpenAPISchema'; describe('getSchemaAlternatives', () => { it('should flatten oneOf', () => { expect( getSchemaAlternatives({ oneOf: [ { oneOf: [ { type: 'number', }, { type: 'boolean', }, ], }, { type: 'string', }, ], }) ).toEqual({ type: 'oneOf', schemas: [ { type: 'number', }, { type: 'boolean', }, { type: 'string', }, ], }); }); it('merges string enum', () => { expect( getSchemaAlternatives({ oneOf: [ { oneOf: [ { type: 'string', enum: ['a', 'b'], }, { type: 'string', enum: ['c', 'd'], nullable: true, }, ], }, ], }) ).toEqual({ type: 'oneOf', schemas: [ { type: 'string', enum: ['a', 'b', 'c', 'd'], nullable: true, }, ], }); }); it('merges objects with allOf', () => { expect( getSchemaAlternatives({ allOf: [ { type: 'object', properties: { name: { type: 'string', }, map: { type: 'string', }, description: { type: 'string', }, }, required: ['name'], }, { type: 'object', properties: { externalId: { type: 'string', }, }, required: ['map', 'externalId'], }, ], }) ).toEqual({ type: 'allOf', schemas: [ { type: 'object', properties: { name: { type: 'string', }, map: { type: 'string', }, description: { type: 'string', }, externalId: { type: 'string', }, }, required: ['name', 'map', 'externalId'], }, ], }); }); it('should not flatten oneOf and allOf', () => { expect( getSchemaAlternatives({ oneOf: [ { allOf: [ { type: 'number', }, { type: 'boolean', }, ], }, { type: 'string', }, ], }) ).toEqual({ type: 'oneOf', schemas: [ { allOf: [ { type: 'number', }, { type: 'boolean', }, ], }, { type: 'string', }, ], }); }); it('should stop at circular references', () => { const a: OpenAPIV3.SchemaObject = { anyOf: [ { type: 'string', }, ], }; a.anyOf?.push(a); expect(getSchemaAlternatives(a)).toEqual({ type: 'anyOf', schemas: [ { type: 'string', }, a, ], }); }); it('should handle non-standard boolean required values without throwing', () => { // Some specs (e.g. Trustly) use `"required": true` on properties // instead of the standard `string[]` format. This should not throw. const schema = { allOf: [ { type: 'object', properties: { name: { type: 'string' }, }, required: true as any, }, { type: 'object', properties: { email: { type: 'string' }, }, required: ['email'], }, ], } as any; const result = getSchemaAlternatives(schema); expect(result).toBeDefined(); // The boolean `required: true` should be ignored, only the valid array is kept expect(result?.schemas[0]?.required).toEqual(['email']); }); it('should handle boolean required on both schemas without throwing', () => { const schema = { allOf: [ { type: 'object', properties: { name: { type: 'string' }, }, required: true as any, }, { type: 'object', properties: { email: { type: 'string' }, }, required: false as any, }, ], } as any; const result = getSchemaAlternatives(schema); expect(result).toBeDefined(); // Boolean required values should not cause a crash, result is an empty array expect(result?.schemas[0]?.required).toEqual([]); }); 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 merge annotation-only schema into object schema', () => { expect( getSchemaAlternatives({ allOf: [ { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, }, required: ['id'], }, { description: 'Overridden description', } as any, ], }) ).toEqual({ type: 'allOf', schemas: [ { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, }, required: ['id'], description: 'Overridden description', }, ], }); }); it('should preserve parent metadata when flattening allOf inside oneOf alternative', () => { const result = getSchemaAlternatives({ oneOf: [ { title: 'Option A', description: 'First option', 'x-custom': 'custom-value', allOf: [ { type: 'object', properties: { name: { type: 'string' }, }, required: ['name'], }, { type: 'object', properties: { age: { type: 'integer' }, }, }, ], }, { type: 'string', }, ], }); expect(result).toEqual({ type: 'oneOf', schemas: [ { title: 'Option A', description: 'First option', 'x-custom': 'custom-value', type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' }, }, required: ['name'], }, { type: 'string', }, ], }); }); it('should merge annotation-only schema with multiple safe extensions', () => { expect( getSchemaAlternatives({ allOf: [ { type: 'object', properties: { id: { type: 'string' }, }, required: ['id'], description: 'Original description', }, { description: 'Overridden description', title: 'Overridden title', deprecated: true, } as any, ], }) ).toEqual({ type: 'allOf', schemas: [ { type: 'object', properties: { id: { type: 'string' }, }, required: ['id'], description: 'Overridden description', title: 'Overridden title', deprecated: true, }, ], }); }); it('should preserve parent metadata when flattening allOf inside anyOf alternative', () => { const result = getSchemaAlternatives({ anyOf: [ { title: 'Variant B', description: 'A variant with extensions', 'x-deprecated-reason': 'use v2', allOf: [ { type: 'object', properties: { id: { type: 'string' }, }, }, { type: 'object', properties: { value: { type: 'number' }, }, }, ], }, ], }); expect(result).toEqual({ type: 'anyOf', schemas: [ { title: 'Variant B', description: 'A variant with extensions', 'x-deprecated-reason': 'use v2', type: 'object', properties: { id: { type: 'string' }, value: { type: 'number' }, }, required: [], }, ], }); }); 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', }, ], }, ], }); }); it('should merge nested allOf with parent properties', () => { const result = getSchemaAlternatives({ allOf: [ { type: 'object', allOf: [ { type: 'object', allOf: [ { type: 'object', properties: { id: { type: 'integer', format: 'int32', }, }, required: ['id'], additionalProperties: false, }, ], properties: { name: { type: 'string', }, }, required: ['name'], additionalProperties: false, }, ], properties: { key: { type: 'string', }, }, required: ['key'], additionalProperties: false, }, ], properties: { labelArgbColor: { type: 'integer', format: 'int32', }, }, required: ['labelArgbColor'], }); expect(result).toMatchObject({ type: 'allOf', schemas: [ { type: 'object', properties: { id: { type: 'integer', format: 'int32', }, name: { type: 'string', }, key: { type: 'string', }, labelArgbColor: { type: 'integer', format: 'int32', }, }, additionalProperties: false, }, ], }); // Check that all required fields are present (order doesn't matter) expect(result?.schemas[0]?.required).toHaveLength(4); expect(result?.schemas[0]?.required).toContain('id'); expect(result?.schemas[0]?.required).toContain('name'); expect(result?.schemas[0]?.required).toContain('key'); 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', () => { it('should merge required fields from allOf schemas', () => { const schema: OpenAPIV3.SchemaObject = { allOf: [ { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'], }, { type: 'object', properties: { name: { type: 'string' } }, required: ['name'], }, ], }; const result = getSchemaProperties(schema); expect(result?.find((p) => p.propertyName === 'id')?.required).toBe(true); expect(result?.find((p) => p.propertyName === 'name')?.required).toBe(true); }); it('should deep-merge overlapping properties from allOf schemas', () => { const schema: OpenAPIV3.SchemaObject = { allOf: [ { type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: { color: { type: 'string' }, category: { type: 'object', properties: { icon: { type: 'string' } }, }, }, }, }, }, }, { type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: { id: { type: 'integer' }, category: { type: 'object', properties: { name: { type: 'string' } }, }, }, }, }, }, }, ], }; const result = getSchemaProperties(schema); const tagItemProps = ( result?.find((p) => p.propertyName === 'tags')?.schema ?.items as OpenAPIV3.SchemaObject )?.properties ?? {}; // Array items merged from both branches expect(tagItemProps).toHaveProperty('id'); expect(tagItemProps).toHaveProperty('color'); // Nested object properties also deep-merged const categoryProps = (tagItemProps.category as OpenAPIV3.SchemaObject)?.properties ?? {}; expect(categoryProps).toHaveProperty('name'); expect(categoryProps).toHaveProperty('icon'); }); });