diff --git a/.changeset/rnd-11617-nullable-anyof.md b/.changeset/rnd-11617-nullable-anyof.md new file mode 100644 index 000000000..b9b114e9d --- /dev/null +++ b/.changeset/rnd-11617-nullable-anyof.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Render `anyOf`/`oneOf` with a `null` member as a nullable schema instead of a `null` union branch. diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index fee855a38..e3ac61b71 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -20,6 +20,7 @@ import { checkIsReference, getEffectiveArrayType, getSchemaTitle, + normalizeNullableUnion, resolveDescription, resolveFirstExample, } from './utils'; @@ -50,12 +51,18 @@ function OpenAPISchemaProperty( circularRefs: parentCircularRefs, context, className, - property, + property: rawProperty, discriminator, discriminatorValue, ...rest } = props; + // Normalize the OpenAPI 3.1+ `anyOf`/`oneOf` + `null` nullability idiom into a plain + // nullable schema before it enters the render pipeline. + const property = { + ...rawProperty, + schema: normalizeNullableUnion(rawProperty.schema), + }; const { schema } = property; const id = useId(); @@ -201,11 +208,14 @@ function OpenAPIRootSchema(props: { circularRefs?: CircularRefsIds; }) { const { - schema, context, circularRefs: parentCircularRefs = new Map(), } = props; + // Normalize the OpenAPI 3.1+ `anyOf`/`oneOf` + `null` nullability idiom into a plain + // nullable schema before it enters the render pipeline. + const schema = normalizeNullableUnion(props.schema); + const id = useId(); const ancestors = new Set(parentCircularRefs.keys()); const alternatives = getSchemaAlternatives(schema, ancestors); diff --git a/packages/react-openapi/src/utils.test.ts b/packages/react-openapi/src/utils.test.ts index 08904a127..c5937a074 100644 --- a/packages/react-openapi/src/utils.test.ts +++ b/packages/react-openapi/src/utils.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'bun:test'; -import type { OpenAPIV3_1 } from '@gitbook/openapi-parser'; -import { extractNonNullTypes, getEffectiveArrayType, getSchemaTitle } from './utils'; +import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser'; +import { + extractNonNullTypes, + getEffectiveArrayType, + getSchemaTitle, + normalizeNullableUnion, +} from './utils'; describe('getSchemaTitle', () => { it('should handle OpenAPI 3.1 nullable array with reference items', () => { @@ -181,3 +186,88 @@ describe('extractNonNullTypes', () => { expect(result.hasNull).toBe(true); }); }); + +describe('normalizeNullableUnion', () => { + it('should collapse anyOf with a single non-null member into a nullable schema', () => { + const schema: OpenAPIV3_1.SchemaObject = { + anyOf: [{ type: 'string' }, { type: 'null' }], + }; + + expect(normalizeNullableUnion(schema)).toEqual({ type: 'string', nullable: true }); + }); + + it('should collapse oneOf with a single object member into a nullable object schema', () => { + const schema: OpenAPIV3_1.SchemaObject = { + oneOf: [ + { + type: 'object', + properties: { id: { type: 'string' } }, + }, + { type: 'null' }, + ], + }; + + expect(normalizeNullableUnion(schema)).toEqual({ + type: 'object', + properties: { id: { type: 'string' } }, + nullable: true, + }); + }); + + it('should handle a null member expressed as type: ["null"]', () => { + const schema: OpenAPIV3_1.SchemaObject = { + anyOf: [{ type: 'integer' }, { type: ['null'] }], + }; + + expect(normalizeNullableUnion(schema)).toEqual({ type: 'integer', nullable: true }); + }); + + it('should keep the union (without null) and flag nullable for multiple non-null members', () => { + const schema: OpenAPIV3_1.SchemaObject = { + anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }], + }; + + expect(normalizeNullableUnion(schema)).toEqual({ + anyOf: [{ type: 'string' }, { type: 'number' }], + nullable: true, + }); + }); + + it('should preserve outer-level metadata over the collapsed member', () => { + const schema: OpenAPIV3_1.SchemaObject = { + description: 'Outer description', + anyOf: [{ type: 'string', description: 'Member description' }, { type: 'null' }], + }; + + expect(normalizeNullableUnion(schema)).toEqual({ + type: 'string', + description: 'Outer description', + nullable: true, + }); + }); + + it('should return the schema unchanged when there is no null member', () => { + const schema: OpenAPIV3.SchemaObject = { + anyOf: [{ type: 'string' }, { type: 'number' }], + }; + + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it('should return the schema unchanged when there is no union', () => { + const schema: OpenAPIV3.SchemaObject = { type: 'string' }; + + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it('should keep a $ref member as a nullable union rather than inlining it', () => { + const schema: OpenAPIV3_1.SchemaObject = { + anyOf: [{ $ref: '#/components/schemas/Foo' }, { type: 'null' }], + }; + + expect(normalizeNullableUnion(schema)).toEqual({ + anyOf: [{ $ref: '#/components/schemas/Foo' }], + nullable: true, + }); + }); +}); diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index 1c6ef6a88..b5e43029b 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -269,6 +269,66 @@ export function getEffectiveArrayType(schema: OpenAPIV3.SchemaObject | OpenAPIV3 return { isArray: false, hasNull: false }; } +/** + * Check if a schema only describes the `null` type, i.e. it is used as a nullability + * marker inside an `anyOf`/`oneOf` (OpenAPI 3.1+). + */ +function isNullSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): boolean { + if (checkIsReference(schema)) { + return false; + } + // `null` is only a valid type in OpenAPI 3.1+, hence the widening. + const type = schema.type as string | string[] | undefined; + if (Array.isArray(type)) { + return type.length > 0 && type.every((t) => t === 'null'); + } + return type === 'null'; +} + +/** + * Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf` + * (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema, + * mirroring how `type: ['string', 'null']` is handled. + * + * - Single non-null member: collapse into that member with `nullable: true`. + * - Multiple non-null members (or a single `$ref` member): drop the null member and keep + * the union, flagged `nullable: true`. + * - No null member: returned unchanged (preserves identity for circular-ref tracking). + */ +export function normalizeNullableUnion( + schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject +): OpenAPIV3.SchemaObject { + const typed = schema as OpenAPIV3.SchemaObject; + + const isAnyOf = Array.isArray(typed.anyOf); + const isOneOf = !isAnyOf && Array.isArray(typed.oneOf); + if (!isAnyOf && !isOneOf) { + return typed; + } + + const unionKey = isAnyOf ? 'anyOf' : 'oneOf'; + const union = (isAnyOf ? typed.anyOf : typed.oneOf) as ( + | OpenAPIV3.SchemaObject + | OpenAPIV3.ReferenceObject + )[]; + + const nonNullMembers = union.filter((member) => !isNullSchema(member)); + if (nonNullMembers.length === union.length) { + // No null member, nothing to normalize. + return typed; + } + + const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = typed; + + const single = nonNullMembers.length === 1 ? nonNullMembers[0] : undefined; + if (single && !checkIsReference(single)) { + // Outer-level metadata (description, title, …) takes precedence over the member's. + return { ...single, ...rest, nullable: true }; + } + + return { ...rest, [unionKey]: nonNullMembers, nullable: true }; +} + export function getSchemaTitle( schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject, options?: { ignoreAlternatives?: boolean }