Fix schema validation error (#2820)

This commit is contained in:
Nolann B.
2025-02-17 15:11:58 +01:00
committed by GitHub
parent dddb4ecbb8
commit f8d4c7697c
18 changed files with 169 additions and 170 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Sync tabs across all OpenAPI blocks
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Support for OpenAPI references
+8 -5
View File
@@ -31,7 +31,7 @@
}, },
"packages/gitbook": { "packages/gitbook": {
"name": "gitbook", "name": "gitbook",
"version": "0.5.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@gitbook/api": "^0.93.0", "@gitbook/api": "^0.93.0",
"@gitbook/cache-do": "workspace:*", "@gitbook/cache-do": "workspace:*",
@@ -118,7 +118,7 @@
}, },
"packages/gitbook-v2": { "packages/gitbook-v2": {
"name": "gitbook-v2", "name": "gitbook-v2",
"version": "0.0.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@gitbook/api": "^0.93.0", "@gitbook/api": "^0.93.0",
"next": "canary", "next": "canary",
@@ -151,7 +151,7 @@
}, },
"packages/openapi-parser": { "packages/openapi-parser": {
"name": "@gitbook/openapi-parser", "name": "@gitbook/openapi-parser",
"version": "0.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@scalar/openapi-parser": "^0.10.4", "@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6", "@scalar/openapi-types": "^0.1.6",
@@ -175,7 +175,7 @@
}, },
"packages/react-contentkit": { "packages/react-contentkit": {
"name": "@gitbook/react-contentkit", "name": "@gitbook/react-contentkit",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@gitbook/api": "^0.93.0", "@gitbook/api": "^0.93.0",
"assert-never": "^1.2.1", "assert-never": "^1.2.1",
@@ -207,7 +207,7 @@
}, },
"packages/react-openapi": { "packages/react-openapi": {
"name": "@gitbook/react-openapi", "name": "@gitbook/react-openapi",
"version": "0.7.1", "version": "1.0.0",
"dependencies": { "dependencies": {
"@gitbook/openapi-parser": "workspace:*", "@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "1.0.87", "@scalar/api-client-react": "1.0.87",
@@ -217,6 +217,7 @@
"react-aria": "^3.37.0", "react-aria": "^3.37.0",
"react-aria-components": "^1.6.0", "react-aria-components": "^1.6.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
"zustand": "^5.0.3",
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.1.20", "bun-types": "^1.1.20",
@@ -3425,6 +3426,8 @@
"zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], "zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="],
"zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
@@ -20,5 +20,6 @@ export async function createFileSystem(input: {
const { filesystem } = await load(input.value, { const { filesystem } = await load(input.value, {
plugins: [fetchURLs({ rootURL: input.rootURL })], plugins: [fetchURLs({ rootURL: input.rootURL })],
}); });
return filesystem; return filesystem;
} }
+2 -1
View File
@@ -18,7 +18,8 @@
"flatted": "^3.2.9", "flatted": "^3.2.9",
"react-aria-components": "^1.6.0", "react-aria-components": "^1.6.0",
"react-aria": "^3.37.0", "react-aria": "^3.37.0",
"usehooks-ts": "^3.1.0" "usehooks-ts": "^3.1.0",
"zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.1.20", "bun-types": "^1.1.20",
@@ -1,7 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback, useRef, useState, useSyncExternalStore } from 'react'; import { useRef, useState } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria'; import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
import { useDisclosureState } from 'react-stately'; import { useDisclosureState } from 'react-stately';
@@ -11,30 +11,6 @@ interface InteractiveSectionTab {
body: React.ReactNode; body: React.ReactNode;
} }
let globalState: Record<string, string> = {};
const listeners = new Set<() => void>();
function useSyncedTabsGlobalState() {
const subscribe = useCallback((callback: () => void) => {
listeners.add(callback);
return () => listeners.delete(callback);
}, []);
const getSnapshot = useCallback(() => globalState, []);
const setSyncedTabs = useCallback(
(updater: (tabs: Record<string, string>) => Record<string, string>) => {
globalState = updater(globalState);
listeners.forEach((listener) => listener());
},
[],
);
const tabs = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return [tabs, setSyncedTabs] as const;
}
/** /**
* To optimize rendering, most of the components are server-components, * To optimize rendering, most of the components are server-components,
* and the interactiveness is mainly handled by a few key components like this one. * and the interactiveness is mainly handled by a few key components like this one.
@@ -59,8 +35,6 @@ export function InteractiveSection(props: {
children?: React.ReactNode; children?: React.ReactNode;
/** Children to display within the container */ /** Children to display within the container */
overlay?: React.ReactNode; overlay?: React.ReactNode;
/** An optional key referencing a value in global state */
stateKey?: string;
}) { }) {
const { const {
id, id,
@@ -73,16 +47,11 @@ export function InteractiveSection(props: {
children, children,
overlay, overlay,
toggleIcon = '▶', toggleIcon = '▶',
stateKey,
} = props; } = props;
const [syncedTabs, setSyncedTabs] = useSyncedTabsGlobalState();
const tabFromState = const [selectedTabKey, setSelectedTab] = useState(defaultTab);
stateKey && stateKey in syncedTabs
? tabs.find((tab) => tab.key === syncedTabs[stateKey])
: undefined;
const [selectedTabKey, setSelectedTab] = useState(tabFromState?.key ?? defaultTab);
const selectedTab: InteractiveSectionTab | undefined = const selectedTab: InteractiveSectionTab | undefined =
tabFromState ?? tabs.find((tab) => tab.key === selectedTabKey) ?? tabs[0]; tabs.find((tab) => tab.key === selectedTabKey) ?? tabs[0];
const state = useDisclosureState({ const state = useDisclosureState({
defaultExpanded: defaultOpened, defaultExpanded: defaultOpened,
@@ -153,12 +122,6 @@ export function InteractiveSection(props: {
value={selectedTab?.key ?? ''} value={selectedTab?.key ?? ''}
onChange={(event) => { onChange={(event) => {
setSelectedTab(event.target.value); setSelectedTab(event.target.value);
if (stateKey) {
setSyncedTabs((state) => ({
...state,
[stateKey]: event.target.value,
}));
}
state.expand(); state.expand();
}} }}
> >
@@ -3,9 +3,10 @@ import { generateMediaTypeExample, generateSchemaExample } from './generateSchem
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
import { getServersURL } from './OpenAPIServerURL'; import { getServersURL } from './OpenAPIServerURL';
import type { OpenAPIContextProps, OpenAPIOperationData } from './types'; import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
import { noReference } from './utils'; import { createStateKey } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI'; import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { checkIsReference } from './utils';
/** /**
* Display code samples to execute the operation. * Display code samples to execute the operation.
@@ -20,24 +21,19 @@ export function OpenAPICodeSample(props: {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
const headersObject: { [k: string]: string } = {}; const headersObject: { [k: string]: string } = {};
data.operation.parameters?.forEach((rawParam) => { data.operation.parameters?.forEach((param) => {
const param = noReference(rawParam);
if (!param) { if (!param) {
return; return;
} }
if (param.in === 'header' && param.required) { if (param.in === 'header' && param.required) {
const example = param.schema const example = param.schema ? generateSchemaExample(param.schema) : undefined;
? generateSchemaExample(noReference(param.schema))
: undefined;
if (example !== undefined && param.name) { if (example !== undefined && param.name) {
headersObject[param.name] = headersObject[param.name] =
typeof example !== 'string' ? stringifyOpenAPI(example) : example; typeof example !== 'string' ? stringifyOpenAPI(example) : example;
} }
} else if (param.in === 'query' && param.required) { } else if (param.in === 'query' && param.required) {
const example = param.schema const example = param.schema ? generateSchemaExample(param.schema) : undefined;
? generateSchemaExample(noReference(param.schema))
: undefined;
if (example !== undefined && param.name) { if (example !== undefined && param.name) {
searchParams.append( searchParams.append(
param.name, param.name,
@@ -47,7 +43,9 @@ export function OpenAPICodeSample(props: {
} }
}); });
const requestBody = noReference(data.operation.requestBody); const requestBody = !checkIsReference(data.operation.requestBody)
? data.operation.requestBody
: undefined;
const requestBodyContentEntries = requestBody?.content const requestBodyContentEntries = requestBody?.content
? Object.entries(requestBody.content) ? Object.entries(requestBody.content)
: undefined; : undefined;
@@ -115,7 +113,7 @@ export function OpenAPICodeSample(props: {
} }
return ( return (
<OpenAPITabs items={samples}> <OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
<InteractiveSection header={<OpenAPITabsList />} className="openapi-codesample"> <InteractiveSection header={<OpenAPITabsList />} className="openapi-codesample">
<OpenAPITabsPanels /> <OpenAPITabsPanels />
</InteractiveSection> </InteractiveSection>
@@ -1,18 +1,22 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPIRootSchema } from './OpenAPISchema'; import { OpenAPIRootSchema } from './OpenAPISchema';
import { noReference } from './utils';
import type { OpenAPIClientContext } from './types'; import type { OpenAPIClientContext } from './types';
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
import { checkIsReference } from './utils';
/** /**
* Display an interactive request body. * Display an interactive request body.
*/ */
export function OpenAPIRequestBody(props: { export function OpenAPIRequestBody(props: {
requestBody: OpenAPIV3.RequestBodyObject; requestBody: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject;
context: OpenAPIClientContext; context: OpenAPIClientContext;
}) { }) {
const { requestBody, context } = props; const { requestBody, context } = props;
if (checkIsReference(requestBody)) {
return null;
}
return ( return (
<InteractiveSection <InteractiveSection
header="Body" header="Body"
@@ -24,7 +28,7 @@ export function OpenAPIRequestBody(props: {
label: contentType, label: contentType,
body: ( body: (
<OpenAPIRootSchema <OpenAPIRootSchema
schema={noReference(mediaTypeObject.schema) ?? {}} schema={mediaTypeObject.schema ?? {}}
context={context} context={context}
/> />
), ),
+4 -18
View File
@@ -1,6 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPISchemaProperties } from './OpenAPISchema'; import { OpenAPISchemaProperties } from './OpenAPISchema';
import { checkIsReference, noReference, resolveDescription } from './utils'; import { resolveDescription } from './utils';
import type { OpenAPIClientContext } from './types'; import type { OpenAPIClientContext } from './types';
import { OpenAPIDisclosure } from './OpenAPIDisclosure'; import { OpenAPIDisclosure } from './OpenAPIDisclosure';
@@ -14,7 +14,7 @@ export function OpenAPIResponse(props: {
}) { }) {
const { response, context, mediaType } = props; const { response, context, mediaType } = props;
const headers = Object.entries(response.headers ?? {}).map( const headers = Object.entries(response.headers ?? {}).map(
([name, header]) => [name, noReference(header) ?? {}] as const, ([name, header]) => [name, header ?? {}] as const,
); );
const content = Object.entries(mediaType.schema ?? {}); const content = Object.entries(mediaType.schema ?? {});
@@ -31,7 +31,7 @@ export function OpenAPIResponse(props: {
<OpenAPISchemaProperties <OpenAPISchemaProperties
properties={headers.map(([name, header]) => ({ properties={headers.map(([name, header]) => ({
propertyName: name, propertyName: name,
schema: noReference(header.schema) ?? {}, schema: header.schema ?? {},
required: header.required, required: header.required,
}))} }))}
context={context} context={context}
@@ -43,7 +43,7 @@ export function OpenAPIResponse(props: {
id={`response-${context.blockKey}`} id={`response-${context.blockKey}`}
properties={[ properties={[
{ {
schema: handleUnresolvedReference(mediaType.schema) ?? {}, schema: mediaType.schema ?? {},
}, },
]} ]}
context={context} context={context}
@@ -52,17 +52,3 @@ export function OpenAPIResponse(props: {
</div> </div>
); );
} }
function handleUnresolvedReference(
input: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined,
): OpenAPIV3.SchemaObject {
const isReference = checkIsReference(input);
if (isReference || input === undefined) {
// If we find a reference that wasn't resolved or needed to be resolved externally, do not try to render it.
// Instead we render `any`
return {};
}
return input;
}
@@ -1,7 +1,7 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { generateSchemaExample } from './generateSchemaExample'; import { generateSchemaExample } from './generateSchemaExample';
import type { OpenAPIContextProps, OpenAPIOperationData } from './types'; import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
import { checkIsReference, noReference, resolveDescription } from './utils'; import { checkIsReference, createStateKey, resolveDescription } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI'; import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
@@ -40,7 +40,7 @@ export function OpenAPIResponseExample(props: {
const examples = responses const examples = responses
.map(([key, value]) => { .map(([key, value]) => {
const responseObject = noReference(value); const responseObject = value;
const mediaTypeObject = (() => { const mediaTypeObject = (() => {
if (!responseObject.content) { if (!responseObject.content) {
return null; return null;
@@ -68,7 +68,7 @@ export function OpenAPIResponseExample(props: {
const key = Object.keys(examples)[0]; const key = Object.keys(examples)[0];
if (key) { if (key) {
// @TODO handle multiple examples // @TODO handle multiple examples
const firstExample = noReference(examples[key]); const firstExample = examples[key];
if (firstExample) { if (firstExample) {
return firstExample; return firstExample;
} }
@@ -79,7 +79,7 @@ export function OpenAPIResponseExample(props: {
return { value: example }; return { value: example };
} }
const schema = noReference(mediaTypeObject.schema); const schema = mediaTypeObject.schema;
if (!schema) { if (!schema) {
return null; return null;
} }
@@ -115,7 +115,7 @@ export function OpenAPIResponseExample(props: {
} }
return ( return (
<OpenAPITabs items={examples}> <OpenAPITabs stateKey={createStateKey('response-example')} items={examples}>
<InteractiveSection header={<OpenAPITabsList />} className="openapi-response-example"> <InteractiveSection header={<OpenAPITabsList />} className="openapi-response-example">
<OpenAPITabsPanels /> <OpenAPITabsPanels />
</InteractiveSection> </InteractiveSection>
@@ -1,9 +1,9 @@
import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser'; import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser';
import { createStateKey, resolveDescription } from './utils';
import { OpenAPIResponse } from './OpenAPIResponse'; import { OpenAPIResponse } from './OpenAPIResponse';
import { OpenAPIClientContext } from './types'; import { OpenAPIClientContext } from './types';
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup'; import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup';
import { Markdown } from './Markdown';
/** /**
* Display an interactive response body. * Display an interactive response body.
@@ -15,18 +15,14 @@ export function OpenAPIResponses(props: {
const { responses, context } = props; const { responses, context } = props;
return ( return (
<InteractiveSection <InteractiveSection header="Responses" className="openapi-responses">
stateKey={createStateKey('response', context.blockKey)}
header="Responses"
className="openapi-responses"
>
<OpenAPIDisclosureGroup <OpenAPIDisclosureGroup
allowsMultipleExpanded allowsMultipleExpanded
icon={context.icons.chevronRight} icon={context.icons.chevronRight}
groups={Object.entries(responses).map( groups={Object.entries(responses).map(
([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => { ([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => {
const content = Object.entries(response.content ?? {}); const content = Object.entries(response.content ?? {});
const description = resolveDescription(response); const description = response.description;
return { return {
id: statusCode, id: statusCode,
@@ -39,9 +35,10 @@ export function OpenAPIResponses(props: {
{statusCode} {statusCode}
</span> </span>
{description ? ( {description ? (
<div className="openapi-markdown openapi-response-description"> <Markdown
{htmlToText(description)} source={description}
</div> className="openapi-response-description"
/>
) : null} ) : null}
</div> </div>
), ),
@@ -64,8 +61,3 @@ export function OpenAPIResponses(props: {
</InteractiveSection> </InteractiveSection>
); );
} }
function htmlToText(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent?.trim() || '';
}
+12 -23
View File
@@ -5,7 +5,7 @@ import { useId } from 'react';
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import type { OpenAPIClientContext } from './types'; import type { OpenAPIClientContext } from './types';
import { checkIsReference, noReference, resolveDescription } from './utils'; import { checkIsReference, resolveDescription } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI'; import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPISchemaName } from './OpenAPISchemaName'; import { OpenAPISchemaName } from './OpenAPISchemaName';
import { OpenAPIDisclosure } from './OpenAPIDisclosure'; import { OpenAPIDisclosure } from './OpenAPIDisclosure';
@@ -70,7 +70,7 @@ export function OpenAPISchemaProperty(
<OpenAPISchemaPresentation {...props} /> <OpenAPISchemaPresentation {...props} />
{alternatives[0].map((alternative, index) => ( {alternatives[0].map((alternative, index) => (
<OpenAPISchemaAlternative <OpenAPISchemaAlternative
key={index} key={`alternative-${index}`}
schema={alternative} schema={alternative}
circularRefs={circularRefs} circularRefs={circularRefs}
context={context} context={context}
@@ -278,9 +278,9 @@ export function OpenAPISchemaPresentation(props: OpenAPISchemaPropertyEntry) {
function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISchemaPropertyEntry[] { function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISchemaPropertyEntry[] {
if (schema.allOf) { if (schema.allOf) {
return schema.allOf.reduce((acc, subSchema) => { return schema.allOf.reduce((acc, subSchema) => {
const properties = getSchemaProperties(noReference(subSchema)) ?? [ const properties = getSchemaProperties(subSchema) ?? [
{ {
schema: noReference(subSchema), schema: subSchema,
}, },
]; ];
return [...acc, ...properties]; return [...acc, ...properties];
@@ -289,7 +289,7 @@ function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISche
// check array AND schema.items as this is sometimes null despite what the type indicates // check array AND schema.items as this is sometimes null despite what the type indicates
if (schema.type === 'array' && !!schema.items) { if (schema.type === 'array' && !!schema.items) {
const items = noReference(schema.items); const items = schema.items;
const itemProperties = getSchemaProperties(items); const itemProperties = getSchemaProperties(items);
if (itemProperties) { if (itemProperties) {
return itemProperties; return itemProperties;
@@ -307,12 +307,7 @@ function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISche
const result: OpenAPISchemaPropertyEntry[] = []; const result: OpenAPISchemaPropertyEntry[] = [];
if (schema.properties) { if (schema.properties) {
Object.entries(schema.properties).forEach(([propertyName, rawPropertySchema]) => { Object.entries(schema.properties).forEach(([propertyName, propertySchema]) => {
const isReference = checkIsReference(rawPropertySchema);
const propertySchema: OpenAPIV3.SchemaObject = isReference
? { propertyName }
: rawPropertySchema;
result.push({ result.push({
propertyName, propertyName,
required: Array.isArray(schema.required) required: Array.isArray(schema.required)
@@ -324,7 +319,7 @@ function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISche
} }
if (schema.additionalProperties) { if (schema.additionalProperties) {
const additionalProperties = noReference(schema.additionalProperties); const additionalProperties = schema.additionalProperties;
result.push({ result.push({
propertyName: 'Other properties', propertyName: 'Other properties',
@@ -348,17 +343,11 @@ export function getSchemaAlternatives(
const downAncestors = new Set(ancestors).add(schema); const downAncestors = new Set(ancestors).add(schema);
if (schema.anyOf) { if (schema.anyOf) {
return [ return [flattenAlternatives('anyOf', schema.anyOf, downAncestors), schema.discriminator];
flattenAlternatives('anyOf', schema.anyOf.map(noReference), downAncestors),
noReference(schema.discriminator),
];
} }
if (schema.oneOf) { if (schema.oneOf) {
return [ return [flattenAlternatives('oneOf', schema.oneOf, downAncestors), schema.discriminator];
flattenAlternatives('oneOf', schema.oneOf.map(noReference), downAncestors),
noReference(schema.discriminator),
];
} }
if (schema.allOf) { if (schema.allOf) {
@@ -396,8 +385,8 @@ export function getSchemaTitle(
// Try using the discriminator // Try using the discriminator
if (discriminator?.propertyName && schema.properties) { if (discriminator?.propertyName && schema.properties) {
const discriminatorProperty = noReference(schema.properties[discriminator.propertyName]); const discriminatorProperty = schema.properties[discriminator.propertyName];
if (discriminatorProperty) { if (discriminatorProperty && !checkIsReference(discriminatorProperty)) {
if (discriminatorProperty.enum) { if (discriminatorProperty.enum) {
return discriminatorProperty.enum.map((value) => value.toString()).join(' | '); return discriminatorProperty.enum.map((value) => value.toString()).join(' | ');
} }
@@ -411,7 +400,7 @@ export function getSchemaTitle(
type = 'enum'; type = 'enum';
// check array AND schema.items as this is sometimes null despite what the type indicates // check array AND schema.items as this is sometimes null despite what the type indicates
} else if (schema.type === 'array' && !!schema.items) { } else if (schema.type === 'array' && !!schema.items) {
type = `${getSchemaTitle(noReference(schema.items))}[]`; type = `${getSchemaTitle(schema.items)}[]`;
} else if (Array.isArray(schema.type)) { } else if (Array.isArray(schema.type)) {
type = schema.type.join(' | '); type = schema.type.join(' | ');
} else if (schema.type || schema.properties) { } else if (schema.type || schema.properties) {
+4 -7
View File
@@ -8,7 +8,7 @@ import { OpenAPIResponses } from './OpenAPIResponses';
import { OpenAPISchemaProperties } from './OpenAPISchema'; import { OpenAPISchemaProperties } from './OpenAPISchema';
import { OpenAPISecurities } from './OpenAPISecurities'; import { OpenAPISecurities } from './OpenAPISecurities';
import type { OpenAPIClientContext, OpenAPIOperationData } from './types'; import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
import { noReference, resolveDescription } from './utils'; import { resolveDescription } from './utils';
/** /**
* Client component to render the spec for the request and response. * Client component to render the spec for the request and response.
@@ -49,7 +49,7 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
example: parameter.example, example: parameter.example,
// Deprecated can be defined at the parameter level // Deprecated can be defined at the parameter level
deprecated: parameter.deprecated, deprecated: parameter.deprecated,
...(noReference(parameter.schema) ?? {}), ...(parameter.schema ?? {}),
}, },
required: parameter.required, required: parameter.required,
}; };
@@ -61,13 +61,10 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
})} })}
{operation.requestBody ? ( {operation.requestBody ? (
<OpenAPIRequestBody <OpenAPIRequestBody requestBody={operation.requestBody} context={context} />
requestBody={noReference(operation.requestBody)}
context={context}
/>
) : null} ) : null}
{operation.responses ? ( {operation.responses ? (
<OpenAPIResponses responses={noReference(operation.responses)} context={context} /> <OpenAPIResponses responses={operation.responses} context={context} />
) : null} ) : null}
</> </>
); );
+63 -23
View File
@@ -1,8 +1,10 @@
'use client'; 'use client';
import { createContext, useContext, useMemo, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Key, Tab, TabList, TabPanel, Tabs, TabsProps } from 'react-aria-components'; import { Key, Tab, TabList, TabPanel, Tabs, TabsProps } from 'react-aria-components';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import { useSyncedTabsGlobalState } from './useSyncedTabsGlobalState';
import { useIntersectionObserver } from 'usehooks-ts';
export type Tab = { export type Tab = {
key: Key; key: Key;
@@ -13,8 +15,7 @@ export type Tab = {
type OpenAPITabsContextData = { type OpenAPITabsContextData = {
items: Tab[]; items: Tab[];
selectedKey: Key; selectedTab: Tab;
setSelectedKey: (key: Key) => void;
}; };
const OpenAPITabsContext = createContext<OpenAPITabsContextData | null>(null); const OpenAPITabsContext = createContext<OpenAPITabsContextData | null>(null);
@@ -30,25 +31,66 @@ function useOpenAPITabsContext() {
/** /**
* The OpenAPI Tabs wrapper component. * The OpenAPI Tabs wrapper component.
*/ */
export function OpenAPITabs(props: React.PropsWithChildren<TabsProps & { items: Tab[] }>) { export function OpenAPITabs(
const { children, items } = props; props: React.PropsWithChildren<TabsProps & { items: Tab[]; stateKey?: string }>,
) {
const [selectedKey, setSelectedKey] = useState(() => { const { children, items, stateKey } = props;
const firstItem = items[0]; const isVisible = stateKey
if (!firstItem) { ? useIntersectionObserver({
throw new Error('OpenAPITabs: at least one tab is required'); threshold: 0.1,
rootMargin: '200px',
})
: true;
const defaultTab = items[0] as Tab;
const [syncedTabs, setSyncedTabs] = useSyncedTabsGlobalState<Tab>();
const [selectedTabKey, setSelectedTabKey] = useState(() => {
if (isVisible && stateKey && syncedTabs && syncedTabs.has(stateKey)) {
const tabFromState = syncedTabs.get(stateKey);
return tabFromState?.key ?? items[0]?.key;
} }
return firstItem.key; return items[0]?.key;
}); });
const [selectedTab, setSelectedTab] = useState<Tab>(defaultTab);
const contextValue = { items, selectedKey, setSelectedKey }; const handleSelectionChange = (key: Key) => {
setSelectedTabKey(key);
if (stateKey) {
const tab = items.find((item) => item.key === key);
if (!tab) {
return;
}
setSyncedTabs((state) => {
const newState = new Map(state);
newState.set(stateKey, tab);
return newState;
});
}
};
useEffect(() => {
if (isVisible && stateKey && syncedTabs && syncedTabs.has(stateKey)) {
const tabFromState = syncedTabs.get(stateKey);
if (!items.some((item) => item.key === tabFromState?.key)) {
return;
}
if (tabFromState && tabFromState?.key !== selectedTab?.key) {
setSelectedTab(tabFromState);
}
}
}, [isVisible, stateKey, syncedTabs, selectedTabKey]);
const contextValue = useMemo(() => ({ items, selectedTab }), [items, selectedTab]);
return ( return (
<OpenAPITabsContext.Provider value={contextValue}> <OpenAPITabsContext.Provider value={contextValue}>
<Tabs <Tabs
className="openapi-tabs" className="openapi-tabs"
onSelectionChange={setSelectedKey} onSelectionChange={handleSelectionChange}
selectedKey={selectedKey} selectedKey={selectedTab?.key}
> >
{children} {children}
</Tabs> </Tabs>
@@ -90,23 +132,21 @@ export function OpenAPITabsList() {
* It renders the content of the selected tab. * It renders the content of the selected tab.
*/ */
export function OpenAPITabsPanels() { export function OpenAPITabsPanels() {
const { selectedKey, items } = useOpenAPITabsContext(); const { selectedTab } = useOpenAPITabsContext();
const tab = useMemo(() => items.find((tab) => tab.key === selectedKey), [items, selectedKey]); if (!selectedTab) {
if (!tab) {
return null; return null;
} }
return ( return (
<TabPanel <TabPanel
key={`TabPanel-${tab.key}`} key={`TabPanel-${selectedTab.key}`}
id={tab.key.toString()} id={selectedTab.key.toString()}
className="openapi-tabs-panel" className="openapi-tabs-panel"
> >
{tab.body} {selectedTab.body}
{tab.description ? ( {selectedTab.description ? (
<Markdown source={tab.description} className="openapi-tabs-footer" /> <Markdown source={selectedTab.description} className="openapi-tabs-footer" />
) : null} ) : null}
</TabPanel> </TabPanel>
); );
@@ -1,5 +1,4 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { noReference } from './utils';
import { getExampleFromSchema } from '@scalar/oas-utils/spec-getters'; import { getExampleFromSchema } from '@scalar/oas-utils/spec-getters';
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
@@ -50,13 +49,13 @@ export function generateMediaTypeExample(
if (key) { if (key) {
const example = mediaType.examples[key]; const example = mediaType.examples[key];
if (example) { if (example) {
return noReference(example).value; return example.value;
} }
} }
} }
if (mediaType.schema) { if (mediaType.schema) {
return generateSchemaExample(noReference(mediaType.schema), options); return generateSchemaExample(mediaType.schema, options);
} }
return undefined; return undefined;
@@ -7,8 +7,8 @@ import {
type OpenAPIV3_1, type OpenAPIV3_1,
dereference, dereference,
} from '@gitbook/openapi-parser'; } from '@gitbook/openapi-parser';
import { noReference } from './utils';
import { OpenAPIOperationData } from './types'; import { OpenAPIOperationData } from './types';
import { checkIsReference } from './utils';
export { toJSON, fromJSON }; export { toJSON, fromJSON };
@@ -48,8 +48,8 @@ export async function resolveOpenAPIOperation(
const securityKey = Object.keys(entry)[0]; const securityKey = Object.keys(entry)[0];
if (securityKey) { if (securityKey) {
const securityScheme = schema.components?.securitySchemes?.[securityKey]; const securityScheme = schema.components?.securitySchemes?.[securityKey];
if (securityScheme) { if (securityScheme && !checkIsReference(securityScheme)) {
securities.push([securityKey, noReference(securityScheme)]); securities.push([securityKey, securityScheme]);
} }
} }
} }
@@ -116,12 +116,13 @@ function getPathObject(
function getPathObjectParameter( function getPathObjectParameter(
schema: OpenAPIV3.Document | OpenAPIV3_1.Document, schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
path: string, path: string,
): OpenAPIV3.ParameterObject[] | OpenAPIV3_1.ParameterObject[] | null { ):
| (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[]
| (OpenAPIV3.ParameterObject | OpenAPIV3_1.ReferenceObject)[]
| null {
const pathObject = getPathObject(schema, path); const pathObject = getPathObject(schema, path);
if (pathObject?.parameters) { if (pathObject?.parameters) {
return pathObject.parameters.map(noReference) as return pathObject.parameters;
| OpenAPIV3.ParameterObject[]
| OpenAPIV3_1.ParameterObject[];
} }
return null; return null;
} }
@@ -0,0 +1,23 @@
'use client';
import { create } from 'zustand';
interface SyncedTabsState<T> {
tabs: Map<string, T>;
setTabs: (updater: (tabs: Map<string, T>) => Map<string, T>) => void;
}
const useSyncedTabsStore = create<SyncedTabsState<any>>()((set) => ({
tabs: new Map<string, any>(),
setTabs: (updater) =>
set((state) => ({
tabs: updater(new Map(state.tabs)), // Ensure a new Map is created for reactivity
})),
}));
// Selector for better performance - only re-renders when tabs change
export function useSyncedTabsGlobalState<T>() {
const tabs = useSyncedTabsStore((state) => state.tabs as Map<string, T>);
const setTabs = useSyncedTabsStore((state) => state.setTabs as SyncedTabsState<T>['setTabs']);
return [tabs, setTabs] as const;
}
-8
View File
@@ -1,13 +1,5 @@
import type { AnyObject, OpenAPIV3 } from '@gitbook/openapi-parser'; import type { AnyObject, OpenAPIV3 } from '@gitbook/openapi-parser';
export function noReference<T>(input: T | OpenAPIV3.ReferenceObject): T {
if (checkIsReference(input)) {
throw new Error('Reference found');
}
return input;
}
export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject { export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject {
return typeof input === 'object' && !!input && '$ref' in input; return typeof input === 'object' && !!input && '$ref' in input;
} }