mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 19:06:31 +00:00
Handle nullable expressed via anyOf/oneOf with a null member (#4366)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Render `anyOf`/`oneOf` with a `null` member as a nullable schema instead of a `null` union branch.
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user