mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +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 { getDisclosureLabel } from './getDisclosureLabel';
|
||||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||||
import { tString } from './translate';
|
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>;
|
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
|
||||||
|
|
||||||
@@ -641,7 +647,8 @@ function getSchemaProperties(
|
|||||||
discriminatorValue?: string | undefined
|
discriminatorValue?: string | undefined
|
||||||
): null | OpenAPISchemaPropertyEntry[] {
|
): null | OpenAPISchemaPropertyEntry[] {
|
||||||
// check array AND schema.items as this is sometimes null despite what the type indicates
|
// 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 items = schema.items;
|
||||||
const itemProperties = getSchemaProperties(items, discriminator, discriminatorValue);
|
const itemProperties = getSchemaProperties(items, discriminator, discriminatorValue);
|
||||||
if (itemProperties) {
|
if (itemProperties) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { OpenAPIClientContext } from './context';
|
import type { OpenAPIClientContext } from './context';
|
||||||
import { t, tString } from './translate';
|
import { t, tString } from './translate';
|
||||||
|
import { getEffectiveArrayType } from './utils';
|
||||||
|
|
||||||
interface OpenAPISchemaNameProps {
|
interface OpenAPISchemaNameProps {
|
||||||
schema?: OpenAPIV3.SchemaObject;
|
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}`;
|
additionalItems += ` · ${tString(context.translation, 'max').toLowerCase()}: ${schema.maximum || schema.maxLength || schema.maxItems}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schema.nullable) {
|
// Check for nullable in both OpenAPI 3.0 (nullable: true) and OpenAPI 3.1 (type: ['null', ...])
|
||||||
additionalItems = ` | ${tString(context.translation, 'nullable').toLowerCase()}`;
|
const schemaArrayInfo = getEffectiveArrayType(schema);
|
||||||
|
const isNullable = schema.nullable || schemaArrayInfo.hasNull;
|
||||||
|
if (isNullable) {
|
||||||
|
additionalItems += ` · ${tString(context.translation, 'nullable').toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return additionalItems;
|
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;
|
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(
|
export function getSchemaTitle(
|
||||||
schema: OpenAPIV3.SchemaObject,
|
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject,
|
||||||
options?: { ignoreAlternatives?: boolean }
|
options?: { ignoreAlternatives?: boolean }
|
||||||
): string {
|
): string {
|
||||||
// Otherwise try to infer a nice title
|
// Otherwise try to infer a nice title
|
||||||
@@ -227,21 +262,48 @@ export function getSchemaTitle(
|
|||||||
|
|
||||||
if (schema.enum || schema['x-enumDescriptions'] || schema['x-gitbook-enum']) {
|
if (schema.enum || schema['x-enumDescriptions'] || schema['x-gitbook-enum']) {
|
||||||
type = `${schema.type} · enum`;
|
type = `${schema.type} · enum`;
|
||||||
// check array AND schema.items as this is sometimes null despite what the type indicates
|
} else {
|
||||||
} else if (schema.type === 'array' && !!schema.items) {
|
// Handle union types (OpenAPI 3.1 nullable)
|
||||||
type = `${getSchemaTitle(schema.items, options)}[]`;
|
const arrayInfo = getEffectiveArrayType(schema);
|
||||||
} else if (Array.isArray(schema.type)) {
|
|
||||||
type = schema.type.join(' | ');
|
|
||||||
} else if (schema.type || schema.properties) {
|
|
||||||
type = schema.type ?? 'object';
|
|
||||||
|
|
||||||
if (schema.format) {
|
if (arrayInfo.isArray && !!schema.items) {
|
||||||
type += ` · ${schema.format}`;
|
// 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 (schema.format) {
|
||||||
if (type === 'object' && schema.title) {
|
type += ` · ${schema.format}`;
|
||||||
type += ` · ${schema.title.replaceAll(' ', '')}`;
|
}
|
||||||
|
|
||||||
|
// 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