mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Add OpenAPI 3.1 nullable array support (#3938)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Add OpenAPI 3.1 nullable array support
|
||||
@@ -16,7 +16,13 @@ import { retrocycle } from './decycle';
|
||||
import { getDisclosureLabel } from './getDisclosureLabel';
|
||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||
import { tString } from './translate';
|
||||
import { checkIsReference, getSchemaTitle, resolveDescription, resolveFirstExample } from './utils';
|
||||
import {
|
||||
checkIsReference,
|
||||
getEffectiveArrayType,
|
||||
getSchemaTitle,
|
||||
resolveDescription,
|
||||
resolveFirstExample,
|
||||
} from './utils';
|
||||
|
||||
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
|
||||
|
||||
@@ -641,7 +647,8 @@ function getSchemaProperties(
|
||||
discriminatorValue?: string | undefined
|
||||
): null | OpenAPISchemaPropertyEntry[] {
|
||||
// check array AND schema.items as this is sometimes null despite what the type indicates
|
||||
if (schema.type === 'array' && schema.items && !checkIsReference(schema.items)) {
|
||||
const arrayInfo = getEffectiveArrayType(schema);
|
||||
if (arrayInfo.isArray && schema.items && !checkIsReference(schema.items)) {
|
||||
const items = schema.items;
|
||||
const itemProperties = getSchemaProperties(items, discriminator, discriminatorValue);
|
||||
if (itemProperties) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||
import type React from 'react';
|
||||
import type { OpenAPIClientContext } from './context';
|
||||
import { t, tString } from './translate';
|
||||
import { getEffectiveArrayType } from './utils';
|
||||
|
||||
interface OpenAPISchemaNameProps {
|
||||
schema?: OpenAPIV3.SchemaObject;
|
||||
@@ -82,8 +83,11 @@ function getAdditionalItems(schema: OpenAPIV3.SchemaObject, context: OpenAPIClie
|
||||
additionalItems += ` · ${tString(context.translation, 'max').toLowerCase()}: ${schema.maximum || schema.maxLength || schema.maxItems}`;
|
||||
}
|
||||
|
||||
if (schema.nullable) {
|
||||
additionalItems = ` | ${tString(context.translation, 'nullable').toLowerCase()}`;
|
||||
// Check for nullable in both OpenAPI 3.0 (nullable: true) and OpenAPI 3.1 (type: ['null', ...])
|
||||
const schemaArrayInfo = getEffectiveArrayType(schema);
|
||||
const isNullable = schema.nullable || schemaArrayInfo.hasNull;
|
||||
if (isNullable) {
|
||||
additionalItems += ` · ${tString(context.translation, 'nullable').toLowerCase()}`;
|
||||
}
|
||||
|
||||
return additionalItems;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
|
||||
import { extractNonNullTypes, getEffectiveArrayType, getSchemaTitle } from './utils';
|
||||
|
||||
describe('getSchemaTitle', () => {
|
||||
it('should handle OpenAPI 3.1 nullable array with reference items', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['null', 'array'],
|
||||
items: {
|
||||
$ref: '#/components/schemas/ProductPayoutSplitDto',
|
||||
},
|
||||
};
|
||||
|
||||
// Nullable is handled separately in getAdditionalItems, so type should not include | null
|
||||
expect(getSchemaTitle(schema)).toBe('ProductPayoutSplitDto[]');
|
||||
});
|
||||
|
||||
it('should handle OpenAPI 3.1 nullable array with object items', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['null', 'array'],
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
// Nullable is handled separately in getAdditionalItems, so type should not include | null
|
||||
expect(getSchemaTitle(schema)).toBe('string[]');
|
||||
});
|
||||
|
||||
it('should handle OpenAPI 3.0 nullable array (backward compatibility)', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: {
|
||||
$ref: '#/components/schemas/ProductPayoutSplitDto',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getSchemaTitle(schema)).toBe('ProductPayoutSplitDto[]');
|
||||
});
|
||||
|
||||
it('should handle non-nullable array with reference items', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/ProductPayoutSplitDto',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getSchemaTitle(schema)).toBe('ProductPayoutSplitDto[]');
|
||||
});
|
||||
|
||||
it('should handle nullable union types (non-array)', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['null', 'string'],
|
||||
};
|
||||
|
||||
// Nullable is handled separately in getAdditionalItems, so type should not include | null
|
||||
expect(getSchemaTitle(schema)).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle multiple union types', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['string', 'number', 'null'],
|
||||
};
|
||||
|
||||
// Nullable is handled separately in getAdditionalItems, so type should show non-null types only
|
||||
expect(getSchemaTitle(schema)).toBe('string | number');
|
||||
});
|
||||
|
||||
it('should handle array with nested object items', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['null', 'array'],
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Nullable is handled separately in getAdditionalItems
|
||||
expect(getSchemaTitle(schema)).toBe('object[]');
|
||||
});
|
||||
|
||||
it('should handle array with primitive items', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['null', 'array'],
|
||||
items: {
|
||||
type: 'integer',
|
||||
},
|
||||
};
|
||||
|
||||
// Nullable is handled separately in getAdditionalItems
|
||||
expect(getSchemaTitle(schema)).toBe('integer[]');
|
||||
});
|
||||
|
||||
it('should handle the exact payout_splits use case', () => {
|
||||
// This matches the exact schema from the user's query
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['null', 'array'],
|
||||
items: {
|
||||
$ref: '#/components/schemas/ProductPayoutSplitDto',
|
||||
},
|
||||
description: 'The payout splits for revenue distribution.\nRequires special approval.',
|
||||
};
|
||||
|
||||
// Nullable is handled separately in getAdditionalItems, so type should be ProductPayoutSplitDto[]
|
||||
expect(getSchemaTitle(schema)).toBe('ProductPayoutSplitDto[]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveArrayType', () => {
|
||||
it('should detect array in OpenAPI 3.1 union type', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['null', 'array'],
|
||||
items: {
|
||||
$ref: '#/components/schemas/ProductPayoutSplitDto',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEffectiveArrayType(schema);
|
||||
expect(result.isArray).toBe(true);
|
||||
expect(result.hasNull).toBe(true);
|
||||
expect(result.items).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect array without null', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEffectiveArrayType(schema);
|
||||
expect(result.isArray).toBe(true);
|
||||
expect(result.hasNull).toBe(false);
|
||||
expect(result.items).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return false for non-array types', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: 'string',
|
||||
};
|
||||
|
||||
const result = getEffectiveArrayType(schema);
|
||||
expect(result.isArray).toBe(false);
|
||||
expect(result.hasNull).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle union type without array', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
type: ['string', 'number'],
|
||||
};
|
||||
|
||||
const result = getEffectiveArrayType(schema);
|
||||
expect(result.isArray).toBe(false);
|
||||
expect(result.hasNull).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNonNullTypes', () => {
|
||||
it('should extract non-null types and detect null', () => {
|
||||
const result = extractNonNullTypes(['null', 'array', 'string']);
|
||||
expect(result.nonNullTypes).toEqual(['array', 'string']);
|
||||
expect(result.hasNull).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle types without null', () => {
|
||||
const result = extractNonNullTypes(['string', 'number']);
|
||||
expect(result.nonNullTypes).toEqual(['string', 'number']);
|
||||
expect(result.hasNull).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle only null', () => {
|
||||
const result = extractNonNullTypes(['null']);
|
||||
expect(result.nonNullTypes).toEqual([]);
|
||||
expect(result.hasNull).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -218,8 +218,43 @@ function getStatusCodeCategory(statusCode: number | string): number | string {
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract non-null types from a union type array.
|
||||
* Returns the types excluding 'null' and whether null was present.
|
||||
*/
|
||||
export function extractNonNullTypes(types: string[]): { nonNullTypes: string[]; hasNull: boolean } {
|
||||
const nonNullTypes = types.filter((t) => t !== 'null');
|
||||
const hasNull = nonNullTypes.length < types.length;
|
||||
return { nonNullTypes, hasNull };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective array type from a schema, handling union types.
|
||||
* Returns the array type if present, or null if not an array.
|
||||
* Handles both OpenAPI 3.0 (type: 'array') and OpenAPI 3.1 (type: ['null', 'array']) nullable arrays.
|
||||
*/
|
||||
export function getEffectiveArrayType(schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject): {
|
||||
isArray: boolean;
|
||||
hasNull: boolean;
|
||||
items?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject;
|
||||
} {
|
||||
if (Array.isArray(schema.type)) {
|
||||
const { nonNullTypes, hasNull } = extractNonNullTypes(schema.type);
|
||||
if (nonNullTypes.includes('array')) {
|
||||
return { isArray: true, hasNull, items: schema.items };
|
||||
}
|
||||
return { isArray: false, hasNull };
|
||||
}
|
||||
|
||||
if (schema.type === 'array') {
|
||||
return { isArray: true, hasNull: false, items: schema.items };
|
||||
}
|
||||
|
||||
return { isArray: false, hasNull: false };
|
||||
}
|
||||
|
||||
export function getSchemaTitle(
|
||||
schema: OpenAPIV3.SchemaObject,
|
||||
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject,
|
||||
options?: { ignoreAlternatives?: boolean }
|
||||
): string {
|
||||
// Otherwise try to infer a nice title
|
||||
@@ -227,21 +262,48 @@ export function getSchemaTitle(
|
||||
|
||||
if (schema.enum || schema['x-enumDescriptions'] || schema['x-gitbook-enum']) {
|
||||
type = `${schema.type} · enum`;
|
||||
// check array AND schema.items as this is sometimes null despite what the type indicates
|
||||
} else if (schema.type === 'array' && !!schema.items) {
|
||||
type = `${getSchemaTitle(schema.items, options)}[]`;
|
||||
} else if (Array.isArray(schema.type)) {
|
||||
type = schema.type.join(' | ');
|
||||
} else if (schema.type || schema.properties) {
|
||||
type = schema.type ?? 'object';
|
||||
} else {
|
||||
// Handle union types (OpenAPI 3.1 nullable)
|
||||
const arrayInfo = getEffectiveArrayType(schema);
|
||||
|
||||
if (schema.format) {
|
||||
type += ` · ${schema.format}`;
|
||||
}
|
||||
if (arrayInfo.isArray && !!schema.items) {
|
||||
// Handle array type (nullable is handled separately in getAdditionalItems)
|
||||
let itemsTitle: string;
|
||||
if (checkIsReference(schema.items)) {
|
||||
// Extract schema name from $ref (e.g., #/components/schemas/ProductPayoutSplitDto -> ProductPayoutSplitDto)
|
||||
const refPath = schema.items.$ref;
|
||||
const schemaName = refPath?.split('/').pop() ?? 'object';
|
||||
itemsTitle = schemaName;
|
||||
} else {
|
||||
itemsTitle = getSchemaTitle(schema.items, options);
|
||||
}
|
||||
type = `${itemsTitle}[]`;
|
||||
} else if (Array.isArray(schema.type)) {
|
||||
// Handle other union types (non-array)
|
||||
// For nullable union types, we show the non-null types only
|
||||
// since nullable is handled separately in getAdditionalItems
|
||||
const { nonNullTypes } = extractNonNullTypes(schema.type);
|
||||
if (nonNullTypes.length === 1) {
|
||||
// Single non-null type - show just that type
|
||||
type = nonNullTypes[0] ?? 'any';
|
||||
} else if (nonNullTypes.length > 1) {
|
||||
// Multiple non-null types - join them (excluding null)
|
||||
type = nonNullTypes.join(' | ');
|
||||
} else {
|
||||
// Only null types - fallback to 'any'
|
||||
type = 'any';
|
||||
}
|
||||
} else if (schema.type || schema.properties) {
|
||||
type = schema.type ?? 'object';
|
||||
|
||||
// Only add the title if it's an object (no need for the title of a string, number, etc.)
|
||||
if (type === 'object' && schema.title) {
|
||||
type += ` · ${schema.title.replaceAll(' ', '')}`;
|
||||
if (schema.format) {
|
||||
type += ` · ${schema.format}`;
|
||||
}
|
||||
|
||||
// Only add the title if it's an object (no need for the title of a string, number, etc.)
|
||||
if (type === 'object' && schema.title) {
|
||||
type += ` · ${schema.title.replaceAll(' ', '')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user