mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-12 16:09:33 +00:00
Stop self-referencing nullable OpenAPI schemas from recursing when expanded (#4665)
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@gitbook/react-openapi": patch
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix OpenAPI schemas that reference themselves through a nullable union rendering forever when expanded, which crashed PDF exports.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { renderToReadableStream } from 'react-dom/server';
|
||||||
|
|
||||||
|
import { parseOpenAPI } from '@gitbook/openapi-parser';
|
||||||
|
|
||||||
|
import type { OpenAPIClientContext } from './context';
|
||||||
|
import { OpenAPIRootSchema } from './OpenAPISchemaServer';
|
||||||
|
import { resolveOpenAPISchemas } from './schemas/resolveOpenAPISchemas';
|
||||||
|
import { translations } from './translations';
|
||||||
|
|
||||||
|
const context: OpenAPIClientContext = {
|
||||||
|
translation: translations.en,
|
||||||
|
icons: {
|
||||||
|
chevronDown: null,
|
||||||
|
chevronRight: null,
|
||||||
|
plus: null,
|
||||||
|
copy: null,
|
||||||
|
check: null,
|
||||||
|
lock: null,
|
||||||
|
mcp: null,
|
||||||
|
hashtag: null,
|
||||||
|
},
|
||||||
|
expandAllModelSections: true,
|
||||||
|
scalarRuntimeURL: '',
|
||||||
|
$$isClientContext$$: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OpenAPIRootSchema', () => {
|
||||||
|
it('should stop at a self-reference behind a nullable union when expanded', async () => {
|
||||||
|
const { filesystem } = await parseOpenAPI({
|
||||||
|
value: JSON.stringify({
|
||||||
|
openapi: '3.1.0',
|
||||||
|
info: { title: 'Test', version: '1.0.0' },
|
||||||
|
paths: {},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
FilterClause: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
AND: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: '#/components/schemas/FilterClause' },
|
||||||
|
},
|
||||||
|
{ type: 'null' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
rootURL: 'memory://spec.json',
|
||||||
|
});
|
||||||
|
const resolved = await resolveOpenAPISchemas(filesystem, { schemas: ['FilterClause'] });
|
||||||
|
const schema = resolved?.schemas[0]?.schema;
|
||||||
|
if (!schema) {
|
||||||
|
throw new Error('FilterClause not resolved');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await renderToReadableStream(
|
||||||
|
<OpenAPIRootSchema schema={schema} context={context} />
|
||||||
|
);
|
||||||
|
const html = await new Response(stream).text();
|
||||||
|
|
||||||
|
expect(html).toContain('AND');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -190,6 +190,14 @@ describe('extractNonNullTypes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('normalizeNullableUnion', () => {
|
describe('normalizeNullableUnion', () => {
|
||||||
|
it('should return the same object for the same input', () => {
|
||||||
|
const schema: OpenAPIV3_1.SchemaObject = {
|
||||||
|
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(normalizeNullableUnion(schema)).toBe(normalizeNullableUnion(schema));
|
||||||
|
});
|
||||||
|
|
||||||
it('should collapse anyOf with a single non-null member into a nullable schema', () => {
|
it('should collapse anyOf with a single non-null member into a nullable schema', () => {
|
||||||
const schema: OpenAPIV3_1.SchemaObject = {
|
const schema: OpenAPIV3_1.SchemaObject = {
|
||||||
anyOf: [{ type: 'string' }, { type: 'null' }],
|
anyOf: [{ type: 'string' }, { type: 'null' }],
|
||||||
|
|||||||
@@ -287,6 +287,10 @@ function isNullSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
|
|||||||
return type === 'null';
|
return type === 'null';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Circular-ref tracking compares schemas by identity: a fresh copy on every render lets a
|
||||||
|
// self-referencing nullable property (`anyOf: [{ items: Self }, null]`) recurse forever when expanded.
|
||||||
|
const normalizedNullableUnions = new WeakMap<OpenAPIV3.SchemaObject, OpenAPIV3.SchemaObject>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf`
|
* Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf`
|
||||||
* (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema,
|
* (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema,
|
||||||
@@ -301,7 +305,17 @@ export function normalizeNullableUnion(
|
|||||||
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject
|
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject
|
||||||
): OpenAPIV3.SchemaObject {
|
): OpenAPIV3.SchemaObject {
|
||||||
const typed = schema as OpenAPIV3.SchemaObject;
|
const typed = schema as OpenAPIV3.SchemaObject;
|
||||||
|
const cached = normalizedNullableUnions.get(typed);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeNullableUnionUncached(typed);
|
||||||
|
normalizedNullableUnions.set(typed, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableUnionUncached(typed: OpenAPIV3.SchemaObject): OpenAPIV3.SchemaObject {
|
||||||
const isAnyOf = Array.isArray(typed.anyOf);
|
const isAnyOf = Array.isArray(typed.anyOf);
|
||||||
const isOneOf = !isAnyOf && Array.isArray(typed.oneOf);
|
const isOneOf = !isAnyOf && Array.isArray(typed.oneOf);
|
||||||
if (!isAnyOf && !isOneOf) {
|
if (!isAnyOf && !isOneOf) {
|
||||||
|
|||||||
Reference in New Issue
Block a user