Handle nullable expressed via anyOf/oneOf with a null member (#4366)

This commit is contained in:
Nolann B.
2026-07-06 12:33:02 +02:00
committed by GitHub
parent fdea8f1c47
commit bcea23e886
4 changed files with 169 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render `anyOf`/`oneOf` with a `null` member as a nullable schema instead of a `null` union branch.
+12 -2
View File
@@ -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<OpenAPIV3.SchemaObject, string>(),
} = 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);
+92 -2
View File
@@ -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,
});
});
});
+60
View File
@@ -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 }