Files
gitbook/packages/react-openapi/src/OpenAPISchema.test.ts
T

767 lines
26 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
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('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');
});
});