diff --git a/.changeset/yellow-fans-hunt.md b/.changeset/yellow-fans-hunt.md new file mode 100644 index 000000000..ce0708991 --- /dev/null +++ b/.changeset/yellow-fans-hunt.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-openapi": patch +--- + +Add OpenAPI 3.1 nullable array support diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index 1cb8e1799..5ce185ce3 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -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; @@ -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) { diff --git a/packages/react-openapi/src/OpenAPISchemaName.tsx b/packages/react-openapi/src/OpenAPISchemaName.tsx index 81cc3e71d..cb5281292 100644 --- a/packages/react-openapi/src/OpenAPISchemaName.tsx +++ b/packages/react-openapi/src/OpenAPISchemaName.tsx @@ -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; diff --git a/packages/react-openapi/src/utils.test.ts b/packages/react-openapi/src/utils.test.ts new file mode 100644 index 000000000..08904a127 --- /dev/null +++ b/packages/react-openapi/src/utils.test.ts @@ -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); + }); +}); diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index 97228e462..72b3ca60b 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -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(' ', '')}`; + } } }