Add OpenAPI 3.1 nullable array support (#3938)

This commit is contained in:
Nolann B.
2026-01-26 14:05:14 +01:00
committed by GitHub
parent 3ba9e46f2b
commit 48ca21e471
5 changed files with 279 additions and 18 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Add OpenAPI 3.1 nullable array support
+9 -2
View File
@@ -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;
+183
View File
@@ -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);
});
});
+76 -14
View File
@@ -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(' ', '')}`;
}
}
}