Skip to content

Fix OpenAPI spec rendering #3037

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/stupid-peaches-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---

Fix spec properties rendering and missing keys
3 changes: 3 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@
"@scalar/oas-utils": "^0.2.120",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
"json-decycle": "^4.0.0",
"json-xml-parse": "^1.3.0",
"react-aria": "^3.37.0",
"react-aria-components": "^1.6.0",
Expand Down Expand Up @@ -2069,6 +2070,8 @@

"json-buffer": ["[email protected]", "", {}, "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ=="],

"json-decycle": ["[email protected]", "", {}, "sha512-3GFL/vWazCbMu1kw+NdIfAHh6Ugq5pxkKcSUnK1f/Fw1nDtt1i+BiBfRJs0iPEKscYAz4k4+osvgjY95hmuJXQ=="],

"json-parse-even-better-errors": ["[email protected]", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],

"json-schema": ["[email protected]", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
Expand Down
4 changes: 2 additions & 2 deletions packages/gitbook/src/components/DocumentView/Blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
const { nodes, blockStyle, isOffscreen: defaultIsOffscreen = false, ...contextProps } = props;

let isOffscreen = defaultIsOffscreen;
return nodes.map((node) => {
return nodes.map((node, index) => {
isOffscreen =
isOffscreen ||
isBlockOffscreen({
Expand All @@ -65,7 +65,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl

return (
<Block
key={node.key}
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'mx-auto w-full decoration-primary/6',
Expand Down
8 changes: 5 additions & 3 deletions packages/gitbook/src/components/DocumentView/Inlines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ export function Inlines<T extends DocumentInline | DocumentText>(
) {
const { nodes, document, ancestorInlines, ...contextProps } = props;

return nodes.map((node) => {
return nodes.map((node, index) => {
const key = node.key || `key-${index}`;

if (node.object === 'text') {
return <Text key={node.key} text={node} />;
return <Text key={key} text={node} />;
}

return (
<Inline
key={node.key}
key={key}
inline={node}
document={document}
ancestorInlines={ancestorInlines}
Expand Down
1 change: 1 addition & 0 deletions packages/react-openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@scalar/oas-utils": "^0.2.120",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
"json-decycle": "^4.0.0",
"json-xml-parse": "^1.3.0",
"react-aria-components": "^1.6.0",
"react-aria": "^3.37.0",
Expand Down
8 changes: 2 additions & 6 deletions packages/react-openapi/src/InteractiveSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ export function InteractiveSection(props: {
defaultTab?: string;
/** Content of the header */
header?: React.ReactNode;
/** Body of the section */
children?: React.ReactNode;
/** Children to display within the container */
overlay?: React.ReactNode;
}) {
Expand All @@ -45,7 +43,6 @@ export function InteractiveSection(props: {
tabs = [],
defaultTab = tabs[0]?.key,
header,
children,
overlay,
toggleIcon = '▶',
} = props;
Expand Down Expand Up @@ -83,7 +80,7 @@ export function InteractiveSection(props: {
className={className}
>
<SectionHeaderContent className={className}>
{(children || selectedTab?.body) && toggeable ? (
{selectedTab?.body && toggeable ? (
<button
{...mergeProps(buttonProps, focusProps)}
ref={triggerRef}
Expand Down Expand Up @@ -131,9 +128,8 @@ export function InteractiveSection(props: {
</div>
</SectionHeader>
) : null}
{(!toggeable || state.isExpanded) && (children || selectedTab?.body) ? (
{(!toggeable || state.isExpanded) && selectedTab?.body ? (
<SectionBody ref={panelRef} {...panelProps} className={className}>
{children}
{selectedTab?.body}
</SectionBody>
) : null}
Expand Down
20 changes: 15 additions & 5 deletions packages/react-openapi/src/OpenAPICodeSample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,13 @@ function generateCodeSamples(props: {
return {
key: `default-${generator.id}`,
label: generator.label,
body: <OpenAPIMediaTypeExamplesBody data={data} renderers={renderers} />,
body: (
<OpenAPIMediaTypeExamplesBody
method={data.method}
path={data.path}
renderers={renderers}
/>
),
footer: (
<OpenAPICodeSampleFooter renderers={renderers} data={data} context={context} />
),
Expand Down Expand Up @@ -189,9 +195,9 @@ function OpenAPICodeSampleFooter(props: {
const { method, path } = data;
const { specUrl } = context;
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
const hasMediaTypes = renderers.length > 0;
const hasMultipleMediaTypes = renderers.length > 1;

if (hideTryItPanel && !hasMediaTypes) {
if (hideTryItPanel && !hasMultipleMediaTypes) {
return null;
}

Expand All @@ -201,8 +207,12 @@ function OpenAPICodeSampleFooter(props: {

return (
<div className="openapi-codesample-footer">
{hasMediaTypes ? (
<OpenAPIMediaTypeExamplesSelector data={data} renderers={renderers} />
{hasMultipleMediaTypes ? (
<OpenAPIMediaTypeExamplesSelector
method={data.method}
path={data.path}
renderers={renderers}
/>
) : (
<span />
)}
Expand Down
36 changes: 19 additions & 17 deletions packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import clsx from 'clsx';
import { useCallback } from 'react';
import { useStore } from 'zustand';
import type { MediaTypeRenderer } from './OpenAPICodeSample';
import type { OpenAPIOperationData } from './types';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';

function useMediaTypeState(data: OpenAPIOperationData, defaultKey: string) {
function useMediaTypeState(data: { method: string; path: string }, defaultKey: string) {
const { method, path } = data;
const store = useStore(getOrCreateTabStoreByKey(`media-type-${method}-${path}`, defaultKey));
if (typeof store.tabKey !== 'string') {
Expand All @@ -18,7 +17,7 @@ function useMediaTypeState(data: OpenAPIOperationData, defaultKey: string) {
};
}

function useMediaTypeSampleIndexState(data: OpenAPIOperationData, mediaType: string) {
function useMediaTypeSampleIndexState(data: { method: string; path: string }, mediaType: string) {
const { method, path } = data;
const store = useStore(
getOrCreateTabStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
Expand All @@ -33,14 +32,15 @@ function useMediaTypeSampleIndexState(data: OpenAPIOperationData, mediaType: str
}

export function OpenAPIMediaTypeExamplesSelector(props: {
data: OpenAPIOperationData;
method: string;
path: string;
renderers: MediaTypeRenderer[];
}) {
const { data, renderers } = props;
const { method, path, renderers } = props;
if (!renderers[0]) {
throw new Error('No renderers provided');
}
const state = useMediaTypeState(data, renderers[0].mediaType);
const state = useMediaTypeState({ method, path }, renderers[0].mediaType);
const selected = renderers.find((r) => r.mediaType === state.mediaType) || renderers[0];

return (
Expand All @@ -56,17 +56,18 @@ export function OpenAPIMediaTypeExamplesSelector(props: {
</option>
))}
</select>
<ExamplesSelector data={data} renderer={selected} />
<ExamplesSelector method={method} path={path} renderer={selected} />
</div>
);
}

function ExamplesSelector(props: {
data: OpenAPIOperationData;
method: string;
path: string;
renderer: MediaTypeRenderer;
}) {
const { data, renderer } = props;
const state = useMediaTypeSampleIndexState(data, renderer.mediaType);
const { method, path, renderer } = props;
const state = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType);
if (renderer.examples.length < 2) {
return null;
}
Expand All @@ -87,25 +88,26 @@ function ExamplesSelector(props: {
}

export function OpenAPIMediaTypeExamplesBody(props: {
data: OpenAPIOperationData;
method: string;
path: string;
renderers: MediaTypeRenderer[];
}) {
const { renderers, data } = props;
const { renderers, method, path } = props;
if (!renderers[0]) {
throw new Error('No renderers provided');
}
const mediaTypeState = useMediaTypeState(data, renderers[0].mediaType);
const mediaTypeState = useMediaTypeState({ method, path }, renderers[0].mediaType);
const selected =
renderers.find((r) => r.mediaType === mediaTypeState.mediaType) ?? renderers[0];
if (selected.examples.length === 0) {
return selected.element;
}
return <ExamplesBody data={data} renderer={selected} />;
return <ExamplesBody method={method} path={path} renderer={selected} />;
}

function ExamplesBody(props: { data: OpenAPIOperationData; renderer: MediaTypeRenderer }) {
const { data, renderer } = props;
const exampleState = useMediaTypeSampleIndexState(data, renderer.mediaType);
function ExamplesBody(props: { method: string; path: string; renderer: MediaTypeRenderer }) {
const { method, path, renderer } = props;
const exampleState = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType);
const example = renderer.examples[exampleState.index] ?? renderer.examples[0];
if (!example) {
throw new Error(`No example found for index ${exampleState.index}`);
Expand Down
4 changes: 2 additions & 2 deletions packages/react-openapi/src/OpenAPIPath.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function formatPath(path: string) {
parts.push(path.slice(lastIndex, offset));
}
parts.push(
<span key={offset} className="openapi-path-variable">
<span key={`offset-${offset}`} className="openapi-path-variable">
{match}
</span>
);
Expand All @@ -61,7 +61,7 @@ function formatPath(path: string) {

const formattedPath = parts.map((part, index) => {
if (typeof part === 'string') {
return <span key={index}>{part}</span>;
return <span key={`part-${index}`}>{part}</span>;
}
return part;
});
Expand Down
2 changes: 1 addition & 1 deletion packages/react-openapi/src/OpenAPIRequestBody.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPIRootSchema } from './OpenAPISchema';
import { OpenAPIRootSchema } from './OpenAPISchemaServer';
import type { OpenAPIClientContext } from './types';
import { checkIsReference } from './utils';

Expand Down
8 changes: 4 additions & 4 deletions packages/react-openapi/src/OpenAPIResponse.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
import { OpenAPISchemaProperties } from './OpenAPISchema';
import { OpenAPISchemaProperties } from './OpenAPISchemaServer';
import type { OpenAPIClientContext } from './types';
import { parameterToProperty, resolveDescription } from './utils';

Expand Down Expand Up @@ -29,9 +29,9 @@ export function OpenAPIResponse(props: {
{headers.length > 0 ? (
<OpenAPIDisclosure context={context} label="Headers">
<OpenAPISchemaProperties
properties={headers.map(([name, header]) => {
return parameterToProperty({ name, ...header });
})}
properties={headers.map(([name, header]) =>
parameterToProperty({ name, ...header })
)}
context={context}
/>
</OpenAPIDisclosure>
Expand Down
6 changes: 1 addition & 5 deletions packages/react-openapi/src/OpenAPIResponses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ export function OpenAPIResponses(props: {
return {
id: statusCode,
label: (
<div
className="openapi-response-tab-content"
key={`response-${statusCode}`}
>
<div className="openapi-response-tab-content">
<span className="openapi-response-statuscode">
{statusCode}
</span>
Expand All @@ -47,7 +44,6 @@ export function OpenAPIResponses(props: {
label: contentType,
body: (
<OpenAPIResponse
key={`$response-${statusCode}-${contentType}`}
response={response}
mediaType={mediaType}
context={context}
Expand Down
Loading