Improve OpenAPIResponses examples and schemas (#3123)

This commit is contained in:
Nolann B.
2025-04-14 11:16:35 +02:00
committed by GitHub
parent a9a643f222
commit 7588cfe1c5
24 changed files with 865 additions and 425 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPIResponses examples and schemas
@@ -8,6 +8,10 @@
@apply flex flex-col mb-14 flex-1;
}
.openapi-schemas-title {
@apply tabular-nums text-[0.813rem] leading-4 font-mono shrink-0 font-medium text-tint-strong;
}
.openapi-columns {
@apply grid grid-cols-1 lg:grid-cols-2 gap-6 print-mode:grid-cols-1 justify-stretch;
}
@@ -46,10 +50,7 @@
@apply font-semibold font-mono truncate;
}
.openapi-description.openapi-markdown {
@apply prose-sm text-[0.938rem];
}
.openapi-description.openapi-markdown,
.openapi-description.openapi-markdown code {
@apply prose-sm;
}
@@ -89,21 +90,23 @@
}
/* Method Tags */
.openapi-method {
@apply rounded uppercase font-mono shrink-0 font-bold text-xs px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
.openapi-method,
.openapi-statuscode {
@apply rounded uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
}
.openapi-method-get {
/* @apply bg-[hsl(215,54%,86%)] dark:bg-[hsla(215,54%,45%,0.24)] dark:text-[hsl(215,54%,86%)]; */
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post {
/* @apply bg-[hsl(120,25%,80%)] dark:bg-[hsla(120,54%,32%,0.24)] dark:text-[hsl(120,25%,80%)]; */
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put {
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
@@ -111,8 +114,9 @@
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete {
@apply bg-pomegranate-100 text-pomegranate-800 dark:bg-pomegranate-900 dark:text-pomegranate-100;
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
@@ -140,7 +144,7 @@
}
.openapi-column-preview-body {
@apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header-sections:page-has-toc:top-32 page-api-block:xl:max-2xl:top-32 print-mode:static;
@apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header-sections:top-32 page-api-block:xl:max-2xl:top-32 print-mode:static;
}
.openapi-column-preview pre {
@@ -157,7 +161,7 @@
}
.openapi-section-schemas > .openapi-section-body > .openapi-schema-root-description {
@apply px-2.5 pt-1 !text-sm;
@apply px-2.5 pt-2.5 mt-0 !text-sm;
}
.openapi-schema-properties {
@@ -360,7 +364,7 @@
/* Responses */
.openapi-responses-header {
@apply py-2 border-b border-tint-subtle max-w-full flex-1;
@apply py-2 max-w-full flex-1;
}
.openapi-responses-header-content {
@@ -368,7 +372,8 @@
}
.openapi-response-tab-content {
@apply overflow-hidden max-w-full flex items-baseline gap-2;
@apply overflow-hidden max-w-full flex items-baseline;
@apply text-left text-pretty relative leading-[1.125rem] text-tint !font-normal truncate select-text;
}
.openapi-response-description.openapi-markdown {
@@ -383,10 +388,6 @@
@apply truncate max-w-full inline pr-1;
}
.openapi-response-statuscode {
@apply tabular-nums text-sm font-normal font-mono shrink-0;
}
.openapi-response-content-type {
@apply text-xs text-tint-8 ml-auto shrink-0;
}
@@ -416,15 +417,23 @@
/* Code Sample */
.openapi-codesample {
@apply border rounded bg-tint border-tint-subtle;
@apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle;
}
.openapi-codesample-header {
@apply flex flex-row items-center;
}
.openapi-codesample-header .openapi-select > button {
@apply border-none;
.openapi-response-media-types-examples-footer-content {
@apply flex flex-row items-center gap-2.5;
}
.openapi-response-examples-header .openapi-select > button {
@apply max-w-full overflow-hidden shrink pl-0.5 py-0.5;
}
.openapi-response-examples-header .openapi-select > button .openapi-statuscode {
@apply h-full;
}
.openapi-codesample-header-content {
@@ -432,7 +441,7 @@
}
.openapi-codesample-header-content .openapi-path {
@apply flex items-center font-mono *:text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
}
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
@@ -463,7 +472,7 @@
}
.openapi-path .openapi-method {
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-1;
@apply m-0 mt-0.5 items-center flex px-1;
}
.openapi-path-title {
@@ -494,7 +503,7 @@
/* Panel */
.openapi-panel {
@apply border rounded bg-tint border-tint-subtle;
@apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle;
}
.openapi-panel-heading {
@@ -516,13 +525,41 @@
}
/* Example */
.openapi-response-examples {
@apply border rounded-md straight-corners:rounded-none bg-tint border-tint-subtle;
}
.openapi-response-examples-header {
@apply flex flex-row items-center p-2.5;
}
.openapi-response-examples-header-content {
@apply max-w-full overflow-hidden truncate;
}
.openapi-response-examples-statuscode-title {
@apply flex items-center;
}
.openapi-response-examples-header .openapi-select > button,
.openapi-response-examples-header .openapi-markdown,
.openapi-response-examples-statuscode-title {
@apply text-[0.813rem] truncate text-tint font-normal;
}
.openapi-response-examples-panel,
.openapi-codesample-panel {
@apply flex-1 text-sm relative focus-visible:outline-none;
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
}
.openapi-example-empty {
@apply relative text-tint bg-tint min-h-20 flex flex-col justify-center items-center;
}
/* Common Elements */
.openapi-select {
/* unstyled */
@apply w-auto max-w-full;
}
/* Prevent react-aria popover from setting overflow:auto on body */
@@ -531,24 +568,32 @@ body:has(.openapi-select-popover) {
}
.openapi-select > button {
@apply flex items-center cursor-pointer gap-1.5 text-tint-strong max-w-60 rounded text-xs leading-6 px-1.5 truncate border border-tint-subtle bg-tint;
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong max-w-32 rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint;
@apply hover:bg-tint-hover transition-all;
}
.openapi-select > button[data-focused="true"] {
@apply outline-primary -outline-offset-1 outline outline-1;
}
.openapi-select > button > span.react-aria-SelectValue {
@apply shrink truncate;
@apply shrink truncate flex items-center;
}
.openapi-select > button > .gb-icon {
@apply shrink-0;
.openapi-select > button > span.react-aria-SelectValue span:not(.openapi-statuscode) {
@apply truncate;
}
.openapi-select > button svg {
@apply size-2.5;
.openapi-select > button .openapi-markdown {
@apply *:leading-none;
}
.openapi-select > button .gb-icon {
@apply size-2.5 shrink-0;
}
.openapi-select-popover {
@apply min-w-32 max-w-fit w-auto max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md;
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
@@ -561,11 +606,17 @@ body:has(.openapi-select-popover) {
}
.openapi-select-item {
@apply text-sm cursor-pointer px-1.5 py-0.5 truncate text-tint ring-0 border-none rounded !outline-none;
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 *:truncate text-tint ring-0 border-none rounded !outline-none;
@apply hover:bg-tint-hover theme-gradient:hover:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
.openapi-select-item-selected {
.openapi-select button .openapi-markdown,
.openapi-select-item .openapi-markdown {
@apply text-[0.813rem] *:truncate;
}
.openapi-select-item-selected,
.openapi-select-item-selected .openapi-markdown {
@apply text-primary-subtle hover:text-primary hover:bg-primary-hover;
@apply theme-muted:hover:bg-primary-active theme-gradient:hover:bg-primary-active tint:font-semibold;
@apply contrast-more:text-primary contrast-more:hover:text-primary-strong contrast-more:font-semibold;
@@ -600,6 +651,14 @@ body:has(.openapi-select-popover) {
@apply text-tint;
}
.openapi-section-footer {
@apply flex flex-row items-center p-2.5 gap-2.5 text-sm text-tint-strong border-t border-tint-subtle;
}
.openapi-section-footer-content {
@apply text-sm text-tint-strong;
}
.openapi-section-toggle {
@apply text-tint-subtle contrast-more:text-tint-strong;
}
@@ -621,6 +680,7 @@ body:has(.openapi-select-popover) {
}
/* Tabs */
.openapi-panel-header,
.openapi-tabs-list {
@apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-scroll;
scrollbar-width: none;
@@ -632,18 +692,17 @@ body:has(.openapi-select-popover) {
}
.openapi-tabs-tab[aria-selected="true"] {
@apply text-primary after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
@apply !text-primary-subtle after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
}
.openapi-tabs-panel,
.openapi-codesample-panel {
.openapi-tabs-panel {
@apply flex-1 text-sm relative focus-visible:outline-none;
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
}
/* Disclosure group */
.openapi-disclosure-group {
@apply border-b border-tint-subtle relative;
@apply border-tint-subtle border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none first:rounded-t-md straight-corners:first:rounded-none first:border-t relative;
}
.openapi-disclosure-group-header {
@@ -651,16 +710,11 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-group-trigger {
@apply flex items-baseline relative flex-1 gap-2.5 py-2 truncate -outline-offset-1;
@apply flex items-baseline transition-all hover:bg-tint-subtle relative flex-1 gap-2.5 p-3 truncate -outline-offset-1;
}
.openapi-disclosure-group-trigger:disabled {
@apply cursor-default;
}
.openapi-disclosure-group:only-child,
.openapi-disclosure-group:last-child {
@apply border-b-0;
@apply cursor-default hover:bg-inherit;
}
.openapi-disclosure-group-trigger:disabled .openapi-disclosure-group-icon {
@@ -680,28 +734,42 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-group-panel {
@apply pb-2.5;
@apply p-3 pt-1 transition-all;
}
.openapi-disclosure-group-trigger[aria-expanded="true"] > .openapi-disclosure-group-icon > svg {
@apply rotate-90;
}
.openapi-disclosure-group:hover .openapi-disclosure-group-mediatype {
@apply opacity-11 flex;
.openapi-disclosure-group:hover .openapi-disclosure-group-mediatype,
.openapi-disclosure-group-mediatype:has(> .openapi-select[data-open="true"]) {
@apply opacity-11 visible flex;
}
.openapi-disclosure-group-mediatype {
@apply opacity-0 hidden text-xs transition-all duration-200 shrink-0 absolute right-0 top-2.5;
@apply opacity-0 invisible text-xs transition-opacity duration-300 shrink-0 absolute right-2.5 top-2.5;
}
.openapi-disclosure-group-mediatype > span {
@apply px-1 bg-tint-6 text-tint-12 rounded-full;
@apply px-1 bg-tint-6 text-tint-12 rounded-full straight-corners:rounded-md;
}
/* Disclosure */
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
@apply flex items-center font-mono !w-full transition-all text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-3 truncate -outline-offset-1;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger,
.openapi-schemas-disclosure .openapi-disclosure-panel {
@apply straight-corners:!rounded-none;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger[aria-expanded="true"] > svg {
@apply rotate-90;
}
.openapi-disclosure-trigger {
@apply transition-all truncate duration-300 max-w-full hover:text-tint-strong rounded-2xl border border-tint-subtle px-2.5 py-1 text-[0.813rem] text-tint flex flex-row items-center gap-1.5 -outline-offset-1;
@apply transition-all truncate duration-300 max-w-full hover:text-tint-strong rounded-2xl straight-corners:rounded border border-tint-subtle px-2.5 py-1 text-[0.813rem] text-tint flex flex-row items-center gap-1.5 -outline-offset-1;
}
.openapi-disclosure-trigger span {
@@ -717,7 +785,7 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-trigger[aria-expanded="true"] {
@apply w-full rounded-lg border-b rounded-b-none;
@apply w-full rounded-lg border-b rounded-b-none straight-corners:rounded-b-none;
}
.openapi-disclosure-trigger[aria-expanded="false"] {
@@ -725,7 +793,7 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-panel[aria-hidden="false"] {
@apply border-b border-x border-tint-subtle rounded-b-lg;
@apply border-b border-x border-tint-subtle rounded-b-lg straight-corners:rounded-b;
}
.openapi-disclosure-panel .openapi-schema {
@@ -745,17 +813,13 @@ body:has(.openapi-select-popover) {
@apply space-y-2.5;
}
.openapi-section-schemas {
@apply border border-tint-subtle rounded-lg;
}
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema,
.openapi-section-schemas > .openapi-section-body > .openapi-schema-root {
@apply p-2.5;
}
.openapi-tooltip {
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md font-medium px-1.5 py-0.5 shadow-sm text-[13px];
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px];
}
.openapi-tooltip svg {
@@ -123,7 +123,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
>
{section.tag ? (
<span
className={`-mt-0.5 openapi-method openapi-method-${section.tag.toLowerCase()}`}
className={`-mt-0.5 openapi-method !text-xs openapi-method-${section.tag.toLowerCase()}`}
>
{section.tag}
</span>
@@ -1,10 +1,10 @@
'use client';
import clsx from 'clsx';
import { useRef, useState } from 'react';
import { useRef } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
import { useDisclosureState } from 'react-stately';
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect';
import { Section, SectionBody, SectionHeader, SectionHeaderContent } from './StaticSection';
interface InteractiveSectionTab {
@@ -35,6 +35,10 @@ export function InteractiveSection(props: {
header?: React.ReactNode;
/** Children to display within the container */
overlay?: React.ReactNode;
/** State key to use with a store */
stateKey?: string;
/** Icon for the tabs select */
selectIcon?: React.ReactNode;
}) {
const {
id,
@@ -46,12 +50,9 @@ export function InteractiveSection(props: {
header,
overlay,
toggleIcon = '▶',
selectIcon,
stateKey = 'interactive-section',
} = props;
const [selectedTabKey, setSelectedTab] = useState(defaultTab);
const selectedTab: InteractiveSectionTab | undefined =
tabs.find((tab) => tab.key === selectedTabKey) ?? tabs[0];
const state = useDisclosureState({
defaultExpanded: defaultOpened,
});
@@ -60,6 +61,10 @@ export function InteractiveSection(props: {
const { buttonProps: triggerProps, panelProps } = useDisclosure({}, state, panelRef);
const { buttonProps } = useButton(triggerProps, triggerRef);
const { isFocusVisible, focusProps } = useFocusRing();
const store = useSelectState(stateKey, defaultTab);
const selectedTab: InteractiveSectionTab | undefined =
tabs.find((tab) => tab.key === store.key) ?? tabs[0];
return (
<Section
@@ -108,16 +113,12 @@ export function InteractiveSection(props: {
>
{tabs.length > 1 ? (
<OpenAPISelect
className={clsx(
'openapi-section-select',
`${className}-tabs-select`
)}
stateKey={stateKey}
items={tabs}
selectedKey={selectedTab?.key ?? ''}
onSelectionChange={(key) => {
setSelectedTab(String(key));
onSelectionChange={() => {
state.expand();
}}
icon={selectIcon}
placement="bottom end"
>
{tabs.map((tab) => (
@@ -206,6 +206,7 @@ function OpenAPICodeSampleFooter(props: {
method={data.method}
path={data.path}
renderers={renderers}
selectIcon={context.icons.chevronDown}
/>
) : (
<span />
@@ -1,70 +1,41 @@
'use client';
import clsx from 'clsx';
import { useCallback } from 'react';
import { useStore } from 'zustand';
import type { MediaTypeRenderer } from './OpenAPICodeSample';
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
type MediaTypeState = {
mediaType: string;
setMediaType: (mediaType: string) => void;
};
function useMediaTypeState(
data: { method: string; path: string },
defaultKey: string
): MediaTypeState {
const { method, path } = data;
const store = useStore(getOrCreateStoreByKey(`media-type-${method}-${path}`, defaultKey));
if (typeof store.key !== 'string') {
throw new Error('Media type key is not a string');
}
return {
mediaType: store.key,
setMediaType: useCallback((index: string) => store.setKey(index), [store.setKey]),
};
}
function useMediaTypeSampleIndexState(data: { method: string; path: string }, mediaType: string) {
const { method, path } = data;
const store = useStore(
getOrCreateStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
);
if (typeof store.key !== 'number') {
throw new Error('Example key is not a number');
}
return {
index: store.key,
setIndex: useCallback((index: number) => store.setKey(index), [store.setKey]),
};
}
import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect';
export function OpenAPIMediaTypeExamplesSelector(props: {
method: string;
path: string;
renderers: MediaTypeRenderer[];
selectIcon?: React.ReactNode;
}) {
const { method, path, renderers } = props;
const { method, path, renderers, selectIcon } = props;
if (!renderers[0]) {
throw new Error('No renderers provided');
}
const state = useMediaTypeState({ method, path }, renderers[0].mediaType);
const selected = renderers.find((r) => r.mediaType === state.mediaType) || renderers[0];
const stateKey = `media-type-${method}-${path}`;
const state = useSelectState(stateKey, renderers[0].mediaType);
const selected = renderers.find((r) => r.mediaType === state.key) || renderers[0];
return (
<div className="openapi-codesample-selectors">
<MediaTypeSelector state={state} renderers={renderers} />
<ExamplesSelector method={method} path={path} renderer={selected} />
<MediaTypeSelector selectIcon={selectIcon} stateKey={stateKey} renderers={renderers} />
<ExamplesSelector
selectIcon={selectIcon}
method={method}
path={path}
renderer={selected}
/>
</div>
);
}
function MediaTypeSelector(props: {
state: MediaTypeState;
stateKey: string;
renderers: MediaTypeRenderer[];
selectIcon?: React.ReactNode;
}) {
const { renderers, state } = props;
const { renderers, stateKey, selectIcon } = props;
if (renderers.length < 2) {
return null;
@@ -78,12 +49,12 @@ function MediaTypeSelector(props: {
return (
<OpenAPISelect
className={clsx('openapi-select')}
selectedKey={state.mediaType}
items={renderers.map((renderer) => ({
key: renderer.mediaType,
label: renderer.mediaType,
}))}
onSelectionChange={(e) => state.setMediaType(String(e))}
icon={selectIcon}
stateKey={stateKey}
placement="bottom start"
>
{items.map((item) => (
@@ -99,9 +70,9 @@ function ExamplesSelector(props: {
method: string;
path: string;
renderer: MediaTypeRenderer;
selectIcon?: React.ReactNode;
}) {
const { method, path, renderer } = props;
const state = useMediaTypeSampleIndexState({ method, path }, renderer.mediaType);
const { method, path, renderer, selectIcon } = props;
if (renderer.examples.length < 2) {
return null;
}
@@ -114,8 +85,8 @@ function ExamplesSelector(props: {
return (
<OpenAPISelect
items={items}
selectedKey={state.index}
onSelectionChange={(e) => state.setIndex(Number(e))}
icon={selectIcon}
stateKey={`media-type-sample-${renderer.mediaType}-${method}-${path}`}
placement="bottom start"
>
{items.map((item) => (
@@ -136,9 +107,9 @@ export function OpenAPIMediaTypeExamplesBody(props: {
if (!renderers[0]) {
throw new Error('No renderers provided');
}
const mediaTypeState = useMediaTypeState({ method, path }, renderers[0].mediaType);
const selected =
renderers.find((r) => r.mediaType === mediaTypeState.mediaType) ?? renderers[0];
const mediaTypeState = useSelectState(`media-type-${method}-${path}`, renderers[0].mediaType);
const selected = renderers.find((r) => r.mediaType === mediaTypeState.key) ?? renderers[0];
if (selected.examples.length === 0) {
return selected.element;
}
@@ -147,10 +118,13 @@ export function OpenAPIMediaTypeExamplesBody(props: {
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];
const exampleState = useSelectState(
`media-type-sample-${renderer.mediaType}-${method}-${path}`,
renderer.mediaType
);
const example = renderer.examples[Number(exampleState.key)] ?? renderer.examples[0];
if (!example) {
throw new Error(`No example found for index ${exampleState.index}`);
throw new Error(`No example found for key ${exampleState.key}`);
}
return example.element;
}
@@ -22,25 +22,21 @@ type CodeSampleItem = OpenAPISelectItem & {
footer?: React.ReactNode;
};
export function OpenAPICodeSampleHeader(props: {
function OpenAPICodeSampleHeader(props: {
items: CodeSampleItem[];
data: OpenAPIOperationData;
selectIcon?: React.ReactNode;
}) {
const { data, items } = props;
const state = useCodeSampleState(items[0]?.key ?? '');
const selected = items.find((item) => item.key === state.key) || items[0];
const { data, items, selectIcon } = props;
return (
<>
<OpenAPIPath canCopy={false} withServer={false} data={data} />
{items.length > 1 ? (
<OpenAPISelect
selectedKey={selected?.key}
onSelectionChange={(key) => {
state.setKey(key);
}}
icon={selectIcon}
items={items}
stateKey="codesample"
placement="bottom end"
>
{items.map((item) => (
@@ -59,8 +55,9 @@ export function OpenAPICodeSampleHeader(props: {
export function OpenAPICodeSampleBody(props: {
items: CodeSampleItem[];
data: OpenAPIOperationData;
selectIcon?: React.ReactNode;
}) {
const { items, data } = props;
const { items, data, selectIcon } = props;
if (!items[0]) {
throw new Error('No items provided');
}
@@ -75,7 +72,7 @@ export function OpenAPICodeSampleBody(props: {
return (
<StaticSection
header={<OpenAPICodeSampleHeader data={data} items={items} />}
header={<OpenAPICodeSampleHeader selectIcon={selectIcon} data={data} items={items} />}
className="openapi-codesample"
>
<div id={selected.key as string} className="openapi-codesample-panel">
@@ -1,41 +1,39 @@
'use client';
import clsx from 'clsx';
import type React from 'react';
import { useState } from 'react';
import { Button, Disclosure, DisclosurePanel, Heading } from 'react-aria-components';
import type { OpenAPIClientContext } from './types';
import { Button, Disclosure, DisclosurePanel } from 'react-aria-components';
/**
* Display an interactive OpenAPI disclosure.
*/
export function OpenAPIDisclosure(props: {
context: OpenAPIClientContext;
icon: React.ReactNode;
children: React.ReactNode;
label: string;
label: string | ((isExpanded: boolean) => string);
className?: string;
}): React.JSX.Element {
const { context, children, label } = props;
const { icon, children, label, className } = props;
const [isExpanded, setIsExpanded] = useState(false);
return (
<Disclosure
className="openapi-disclosure"
className={clsx('openapi-disclosure', className)}
isExpanded={isExpanded}
onExpandedChange={setIsExpanded}
>
<Heading>
<Button
slot="trigger"
className="openapi-disclosure-trigger"
style={({ isFocusVisible }) => ({
outline: isFocusVisible
? '2px solid rgb(var(--primary-color-500) / 0.4)'
: 'none',
})}
>
{context.icons.plus}
<span>
{isExpanded ? 'Hide' : 'Show'} {label}
</span>
</Button>
</Heading>
<Button
slot="trigger"
className="openapi-disclosure-trigger"
style={({ isFocusVisible }) => ({
outline: isFocusVisible
? '2px solid rgb(var(--primary-color-500) / 0.4)'
: 'none',
})}
>
{icon}
<span>{typeof label === 'function' ? label(isExpanded) : label}</span>
</Button>
<DisclosurePanel className="openapi-disclosure-panel">
{isExpanded ? children : null}
</DisclosurePanel>
@@ -1,6 +1,6 @@
'use client';
import { createContext, useContext, useRef, useState } from 'react';
import { createContext, useContext, useRef } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing, useId } from 'react-aria';
import {
type DisclosureGroupProps,
@@ -8,18 +8,23 @@ import {
useDisclosureGroupState,
useDisclosureState,
} from 'react-stately';
import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect';
interface Props {
groups: TDisclosureGroup[];
icon?: React.ReactNode;
/** State key to use with a store */
selectStateKey?: string;
/** Icon to display for the select */
selectIcon?: React.ReactNode;
}
type TDisclosureGroup = {
id: string;
key: string;
label: string | React.ReactNode;
tabs?: {
id: string;
label?: string | React.ReactNode;
key: string;
label: string | React.ReactNode;
body?: React.ReactNode;
}[];
};
@@ -30,24 +35,35 @@ const DisclosureGroupStateContext = createContext<DisclosureGroupState | null>(n
* Display an interactive OpenAPI disclosure group.
*/
export function OpenAPIDisclosureGroup(props: DisclosureGroupProps & Props) {
const { icon, groups } = props;
const { icon, groups, selectStateKey, selectIcon } = props;
const state = useDisclosureGroupState(props);
return (
<DisclosureGroupStateContext.Provider value={state}>
{groups.map((group) => (
<DisclosureItem icon={icon} key={group.id} group={group} />
<DisclosureItem
selectStateKey={selectStateKey}
selectIcon={selectIcon}
icon={icon}
key={group.key}
group={group}
/>
))}
</DisclosureGroupStateContext.Provider>
);
}
function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode }) {
const { icon, group } = props;
function DisclosureItem(props: {
group: TDisclosureGroup;
icon?: React.ReactNode;
selectStateKey?: string;
selectIcon?: React.ReactNode;
}) {
const { icon, group, selectStateKey, selectIcon } = props;
const defaultId = useId();
const id = group.id || defaultId;
const id = group.key || defaultId;
const groupState = useContext(DisclosureGroupStateContext);
const isExpanded = groupState?.expandedKeys.has(id) || false;
const state = useDisclosureState({
@@ -74,9 +90,9 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode
const { buttonProps } = useButton(triggerProps, triggerRef);
const { isFocusVisible, focusProps } = useFocusRing();
const defaultTab = group.tabs?.[0]?.id || '';
const [selectedTabKey, setSelectedTabKey] = useState(defaultTab);
const selectedTab = group.tabs?.find((tab) => tab.id === selectedTabKey);
const defaultTab = group.tabs?.[0]?.key || '';
const store = useSelectState(selectStateKey, defaultTab);
const selectedTab = group.tabs?.find((tab) => tab.key === store.key) || group.tabs?.[0];
return (
<div className="openapi-disclosure-group" aria-expanded={state.isExpanded}>
@@ -104,23 +120,25 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode
{group.label}
</button>
{group.tabs ? (
<div className="openapi-disclosure-group-mediatype">
<div
className="openapi-disclosure-group-mediatype"
onClick={(e) => e.stopPropagation()}
>
{group.tabs?.length > 1 ? (
<select
className="openapi-section-select openapi-select openapi-disclosure-group-tabs-select"
onClick={(event) => event.stopPropagation()}
value={selectedTab?.id}
onChange={(event) => {
setSelectedTabKey(event.target.value);
<OpenAPISelect
icon={selectIcon}
stateKey={selectStateKey}
onSelectionChange={() => {
state.expand();
}}
items={group.tabs}
>
{group.tabs.map((tab) => (
<option key={tab.id} value={tab.id}>
<OpenAPISelectItem key={tab.key} id={tab.key} value={tab}>
{tab.label}
</option>
</OpenAPISelectItem>
))}
</select>
</OpenAPISelect>
) : group.tabs[0]?.label ? (
<span>{group.tabs[0].label}</span>
) : null}
@@ -1,7 +1,7 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPIRootSchema } from './OpenAPISchemaServer';
import type { OpenAPIClientContext } from './types';
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
import { checkIsReference } from './utils';
/**
@@ -10,8 +10,10 @@ import { checkIsReference } from './utils';
export function OpenAPIRequestBody(props: {
requestBody: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject;
context: OpenAPIClientContext;
data: OpenAPIOperationData;
}) {
const { requestBody, context } = props;
const { requestBody, context, data } = props;
const { method, path } = data;
if (checkIsReference(requestBody)) {
return null;
@@ -21,6 +23,8 @@ export function OpenAPIRequestBody(props: {
<InteractiveSection
header="Body"
className="openapi-requestbody"
stateKey={`media-type-${method}-${path}`}
selectIcon={context.icons.chevronDown}
tabs={Object.entries(requestBody.content ?? {}).map(
([contentType, mediaTypeObject]) => {
return {
@@ -27,7 +27,7 @@ export function OpenAPIResponse(props: {
return (
<div className="openapi-response-body">
{headers.length > 0 ? (
<OpenAPIDisclosure context={context} label="Headers">
<OpenAPIDisclosure icon={context.icons.plus} label="Headers">
<OpenAPISchemaProperties
properties={headers.map(([name, header]) =>
parameterToProperty({ name, ...header })
@@ -6,10 +6,11 @@ import {
getExampleFromReference,
getExamplesFromMediaTypeObject,
} from './OpenAPIExample';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { StaticSection } from './StaticSection';
import { OpenAPIResponseExampleContent } from './OpenAPIResponseExampleContent';
import { OpenAPIResponseMediaTypeContent } from './OpenAPIResponseMediaType';
import type { OpenAPIContext, OpenAPIOperationData } from './types';
import { checkIsReference, createStateKey, resolveDescription } from './utils';
import { getStatusCodeDefaultLabel } from './utils';
import { checkIsReference, resolveDescription } from './utils';
/**
* Display an example of the response content.
@@ -45,11 +46,17 @@ export function OpenAPIResponseExample(props: {
const tabs = responses.map(([key, responseObject]) => {
const description = resolveDescription(responseObject);
const label = description ? (
<Markdown source={description} />
) : (
getStatusCodeDefaultLabel(key)
);
if (checkIsReference(responseObject)) {
return {
key: key,
label: key,
label,
statusCode: key,
body: (
<OpenAPIExample
example={getExampleFromReference(responseObject)}
@@ -57,24 +64,23 @@ export function OpenAPIResponseExample(props: {
syntax="json"
/>
),
footer: description ? <Markdown source={description} /> : undefined,
};
}
if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
return {
key: key,
label: key,
label,
statusCode: key,
body: <OpenAPIEmptyExample />,
footer: description ? <Markdown source={description} /> : undefined,
};
}
return {
key: key,
label: key,
label,
statusCode: key,
body: <OpenAPIResponse context={context} content={responseObject.content} />,
footer: description ? <Markdown source={description} /> : undefined,
};
});
@@ -83,11 +89,11 @@ export function OpenAPIResponseExample(props: {
}
return (
<OpenAPITabs stateKey={createStateKey('response-example')} items={tabs}>
<StaticSection header={<OpenAPITabsList />} className="openapi-panel">
<OpenAPITabsPanels />
</StaticSection>
</OpenAPITabs>
<OpenAPIResponseExampleContent
selectIcon={context.icons.chevronDown}
blockKey={context.blockKey}
items={tabs}
/>
);
}
@@ -106,42 +112,30 @@ function OpenAPIResponse(props: {
throw new Error('One media type is required');
}
if (entries.length === 1) {
const [mediaType, mediaTypeObject] = firstEntry;
return (
<OpenAPIResponseMediaType
context={context}
mediaType={mediaType}
mediaTypeObject={mediaTypeObject}
/>
);
}
const tabs = entries.map((entry) => {
const [mediaType, mediaTypeObject] = entry;
return {
key: mediaType,
label: mediaType,
body: (
<OpenAPIResponseMediaType
context={context}
mediaType={mediaType}
mediaTypeObject={mediaTypeObject}
/>
),
body: <></>,
examples: getExamples({
mediaTypeObject,
mediaType,
context,
}),
};
});
return (
<OpenAPITabs stateKey={createStateKey('response-media-types')} items={tabs}>
<StaticSection header={<OpenAPITabsList />} className="openapi-response-media-types">
<OpenAPITabsPanels />
</StaticSection>
</OpenAPITabs>
<OpenAPIResponseMediaTypeContent
selectIcon={context.icons.chevronDown}
blockKey={context.blockKey}
items={tabs}
/>
);
}
function OpenAPIResponseMediaType(props: {
function getExamples(props: {
mediaTypeObject: OpenAPIV3.MediaTypeObject;
mediaType: string;
context: OpenAPIContext;
@@ -149,23 +143,8 @@ function OpenAPIResponseMediaType(props: {
const { mediaTypeObject, mediaType } = props;
const examples = getExamplesFromMediaTypeObject({ mediaTypeObject, mediaType });
const syntax = getSyntaxFromMediaType(mediaType);
const firstExample = examples[0];
if (!firstExample) {
return <OpenAPIEmptyExample />;
}
if (examples.length === 1) {
return (
<OpenAPIExample
example={firstExample.example}
context={props.context}
syntax={syntax}
/>
);
}
const tabs = examples.map((example) => {
return examples.map((example) => {
return {
key: example.key,
label: example.example.summary || example.key,
@@ -174,17 +153,6 @@ function OpenAPIResponseMediaType(props: {
),
};
});
return (
<OpenAPITabs stateKey={createStateKey('response-media-type-examples')} items={tabs}>
<StaticSection
header={<OpenAPITabsList />}
className="openapi-response-media-type-examples"
>
<OpenAPITabsPanels />
</StaticSection>
</OpenAPITabs>
);
}
/**
@@ -0,0 +1,123 @@
'use client';
import clsx from 'clsx';
import type { Key } from 'react-aria';
import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect';
import { StaticSection } from './StaticSection';
import { createStateKey, getStatusCodeClassName } from './utils';
type OpenAPIResponseExampleItem = OpenAPISelectItem & {
statusCode: string;
body: React.ReactNode;
};
/**
* Get the state of the response examples select.
*/
export function useResponseExamplesState(
blockKey: string | undefined,
initialKey: Key = 'default'
) {
return useSelectState(getResponseExampleStateKey(blockKey), initialKey);
}
export function OpenAPIResponseExampleContent(props: {
items: OpenAPIResponseExampleItem[];
blockKey?: string;
selectIcon?: React.ReactNode;
}) {
const { blockKey, items, selectIcon } = props;
return (
<StaticSection
header={
<OpenAPIResponseExampleHeader
selectIcon={selectIcon}
blockKey={blockKey}
items={items}
/>
}
className="openapi-response-examples"
>
<OpenAPIResponseExampleBody blockKey={blockKey} items={items} />
</StaticSection>
);
}
function OpenAPIResponseExampleHeader(props: {
items: OpenAPIResponseExampleItem[];
blockKey?: string;
selectIcon?: React.ReactNode;
}) {
const { items, blockKey, selectIcon } = props;
if (items.length === 1) {
const item = items[0];
if (!item) {
return null;
}
return (
<span className="openapi-response-examples-statuscode-title">
<span
className={clsx(
'openapi-statuscode',
`openapi-statuscode-${getStatusCodeClassName(item.statusCode)}`,
'openapi-response-examples-statuscode'
)}
>
{item.statusCode}
</span>
<span>{item.label}</span>
</span>
);
}
return (
<OpenAPISelect
items={items}
icon={selectIcon}
stateKey={getResponseExampleStateKey(blockKey)}
placement="bottom start"
>
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
<span
className={clsx(
'openapi-statuscode',
`openapi-statuscode-${getStatusCodeClassName(item.statusCode)}`,
'openapi-response-examples-statuscode'
)}
>
{item.statusCode}
</span>
<span>{item.label}</span>
</OpenAPISelectItem>
))}
</OpenAPISelect>
);
}
function OpenAPIResponseExampleBody(props: {
items: OpenAPIResponseExampleItem[];
blockKey?: string;
}) {
const { blockKey, items } = props;
const state = useResponseExamplesState(blockKey, items[0]?.key);
const selectedItem = items.find((item) => item.key === state.key) ?? items[0];
if (!selectedItem) {
return null;
}
return <div className="openapi-response-examples-panel">{selectedItem.body}</div>;
}
/**
* Return the state key for the response examples.
*/
function getResponseExampleStateKey(blockKey: string | undefined) {
return createStateKey('openapi-responses', blockKey);
}
@@ -0,0 +1,148 @@
'use client';
import type { Key } from 'react-aria';
import { OpenAPIEmptyExample } from './OpenAPIExample';
import { OpenAPISelect, OpenAPISelectItem, useSelectState } from './OpenAPISelect';
import { StaticSection } from './StaticSection';
import { createStateKey } from './utils';
type OpenAPIResponseMediaTypeItem = OpenAPISelectItem & {
body: React.ReactNode;
examples?: OpenAPIResponseMediaTypeItem[];
};
/**
* Get the state of the response examples select.
*/
export function useResponseMediaTypesState(
blockKey: string | undefined,
initialKey: Key = 'default'
) {
return useSelectState(getResponseMediaTypeStateKey(blockKey), initialKey);
}
function useResponseMediaTypeExamplesState(
blockKey: string | undefined,
initialKey: Key = 'default'
) {
return useSelectState(getResponseMediaTypeExamplesStateKey(blockKey), initialKey);
}
export function OpenAPIResponseMediaTypeContent(props: {
items: OpenAPIResponseMediaTypeItem[];
blockKey?: string;
selectIcon?: React.ReactNode;
}) {
const { blockKey, items, selectIcon } = props;
const state = useResponseMediaTypesState(blockKey, items[0]?.key);
const examples = items.find((item) => item.key === state.key)?.examples ?? [];
if (!items.length && !examples.length) {
return null;
}
return (
<StaticSection
footer={
items.length > 1 || examples.length > 1 ? (
<OpenAPIResponseMediaTypeFooter
blockKey={blockKey}
items={items}
examples={examples}
selectIcon={selectIcon}
/>
) : null
}
className="openapi-response-media-types-examples"
>
<OpenAPIResponseMediaTypeBody blockKey={blockKey} items={items} examples={examples} />
</StaticSection>
);
}
function OpenAPIResponseMediaTypeFooter(props: {
items: OpenAPIResponseMediaTypeItem[];
examples?: OpenAPIResponseMediaTypeItem[];
blockKey?: string;
selectIcon?: React.ReactNode;
}) {
const { items, examples, blockKey, selectIcon } = props;
return (
<>
{items.length > 1 && (
<OpenAPISelect
icon={selectIcon}
items={items}
stateKey={getResponseMediaTypeStateKey(blockKey)}
placement="bottom start"
>
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
<span>{item.label}</span>
</OpenAPISelectItem>
))}
</OpenAPISelect>
)}
{examples && examples.length > 1 ? (
<OpenAPISelect
icon={selectIcon}
items={examples}
stateKey={getResponseMediaTypeExamplesStateKey(blockKey)}
placement="bottom start"
>
{examples.map((example) => (
<OpenAPISelectItem key={example.key} id={example.key} value={example}>
<span>{example.label}</span>
</OpenAPISelectItem>
))}
</OpenAPISelect>
) : null}
</>
);
}
function OpenAPIResponseMediaTypeBody(props: {
items: OpenAPIResponseMediaTypeItem[];
examples?: OpenAPIResponseMediaTypeItem[];
blockKey?: string;
}) {
const { blockKey, items, examples } = props;
const state = useResponseMediaTypesState(blockKey, items[0]?.key);
const selectedItem = items.find((item) => item.key === state.key) ?? items[0];
const exampleState = useResponseMediaTypeExamplesState(
blockKey,
selectedItem?.examples?.[0]?.key
);
if (!selectedItem) {
return null;
}
if (examples) {
const selectedExample =
examples.find((example) => example.key === exampleState.key) ?? examples[0];
if (!selectedExample) {
return <OpenAPIEmptyExample />;
}
return selectedExample.body;
}
return selectedItem.body;
}
/**
* Return the state key for the response media types.
*/
function getResponseMediaTypeStateKey(blockKey: string | undefined) {
return createStateKey('response-media-types', blockKey);
}
function getResponseMediaTypeExamplesStateKey(blockKey: string | undefined) {
return createStateKey('response-media-types-examples', blockKey);
}
+80 -60
View File
@@ -1,9 +1,14 @@
'use client';
import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser';
import clsx from 'clsx';
import { Markdown } from './Markdown';
import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup';
import { OpenAPIResponse } from './OpenAPIResponse';
import { useResponseExamplesState } from './OpenAPIResponseExampleContent';
import { StaticSection } from './StaticSection';
import type { OpenAPIClientContext } from './types';
import { createStateKey, getStatusCodeClassName, getStatusCodeDefaultLabel } from './utils';
/**
* Display an interactive response body.
@@ -14,70 +19,85 @@ export function OpenAPIResponses(props: {
}) {
const { responses, context } = props;
const groups = Object.entries(responses).map(
([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => {
const tabs = (() => {
// If there is no content, but there are headers, we need to show the headers
if (
(!response.content || !Object.keys(response.content).length) &&
response.headers &&
Object.keys(response.headers).length
) {
return [
{
key: 'default',
label: '',
body: (
<OpenAPIResponse
response={response}
mediaType={{}}
context={context}
/>
),
},
];
}
return Object.entries(response.content ?? {}).map(([contentType, mediaType]) => ({
key: contentType,
label: contentType,
body: (
<OpenAPIResponse
response={response}
mediaType={mediaType}
context={context}
/>
),
}));
})();
const description = response.description;
return {
key: statusCode,
label: (
<div className="openapi-response-tab-content">
<span
className={clsx(
'openapi-statuscode',
`openapi-statuscode-${getStatusCodeClassName(statusCode)}`
)}
>
{statusCode}
</span>
{description ? (
<Markdown
source={description}
className="openapi-response-description"
/>
) : (
getStatusCodeDefaultLabel(statusCode)
)}
</div>
),
tabs,
};
}
);
const state = useResponseExamplesState(context.blockKey, groups[0]?.key);
return (
<StaticSection header="Responses" className="openapi-responses">
<OpenAPIDisclosureGroup
allowsMultipleExpanded
icon={context.icons.chevronRight}
groups={Object.entries(responses).map(
([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => {
const tabs = (() => {
// If there is no content, but there are headers, we need to show the headers
if (
(!response.content || !Object.keys(response.content).length) &&
response.headers &&
Object.keys(response.headers).length
) {
return [
{
id: 'default',
body: (
<OpenAPIResponse
response={response}
mediaType={{}}
context={context}
/>
),
},
];
}
return Object.entries(response.content ?? {}).map(
([contentType, mediaType]) => ({
id: contentType,
label: contentType,
body: (
<OpenAPIResponse
response={response}
mediaType={mediaType}
context={context}
/>
),
})
);
})();
const description = response.description;
return {
id: statusCode,
label: (
<div className="openapi-response-tab-content">
<span className="openapi-response-statuscode">
{statusCode}
</span>
{description ? (
<Markdown
source={description}
className="openapi-response-description"
/>
) : null}
</div>
),
tabs,
};
}
)}
expandedKeys={state.key ? new Set([state.key]) : new Set()}
onExpandedChange={(keys) => {
const key = keys.values().next().value ?? null;
state.setKey(key);
}}
groups={groups}
selectStateKey={createStateKey('response-media-types', context.blockKey)}
/>
</StaticSection>
);
+18 -10
View File
@@ -54,7 +54,10 @@ function OpenAPISchemaProperty(props: {
const properties = getSchemaProperties(schema);
if (properties?.length) {
return (
<OpenAPIDisclosure context={context} label={getDisclosureLabel(schema)}>
<OpenAPIDisclosure
icon={context.icons.plus}
label={(isExpanded) => getDisclosureLabel(schema, isExpanded)}
>
<OpenAPISchemaProperties
properties={properties}
circularRefs={circularRefs}
@@ -208,7 +211,10 @@ function OpenAPISchemaAlternative(props: {
{description ? (
<Markdown source={description} className="openapi-schema-description" />
) : null}
<OpenAPIDisclosure context={context} label={getDisclosureLabel(schema)}>
<OpenAPIDisclosure
icon={context.icons.plus}
label={(isExpanded) => getDisclosureLabel(schema, isExpanded)}
>
{properties?.length ? (
<OpenAPISchemaProperties
properties={properties}
@@ -502,19 +508,21 @@ function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
return type;
}
function getDisclosureLabel(schema: OpenAPIV3.SchemaObject): string {
function getDisclosureLabel(schema: OpenAPIV3.SchemaObject, isExpanded: boolean) {
let label: string;
if (schema.type === 'array' && !!schema.items) {
if (schema.items.oneOf) {
return 'available items';
label = 'available items';
}
// Fallback to "child attributes" for enums and objects
if (schema.items.enum || schema.items.type === 'object') {
return 'child attributes';
else if (schema.items.enum || schema.items.type === 'object') {
label = 'child attributes';
} else {
label = schema.items.title ?? schema.title ?? getSchemaTitle(schema.items);
}
return schema.items.title ?? schema.title ?? getSchemaTitle(schema.items);
} else {
label = schema.title || 'child attributes';
}
return schema.title || 'child attributes';
return `${isExpanded ? 'Hide' : 'Show'} ${label}`;
}
@@ -6,7 +6,7 @@ import type {
OpenAPIOperationData,
OpenAPISecurityWithRequired,
} from './types';
import { resolveDescription } from './utils';
import { createStateKey, resolveDescription } from './utils';
/**
* Present securities authorization that can be used for this operation.
@@ -24,9 +24,11 @@ export function OpenAPISecurities(props: {
return (
<InteractiveSection
header="Authorizations"
stateKey={createStateKey('securities', context.blockKey)}
toggeable
defaultOpened={false}
toggleIcon={context.icons.chevronRight}
selectIcon={context.icons.chevronDown}
className="openapi-securities"
tabs={securities.map(([key, security]) => {
const description = resolveDescription(security);
+42 -16
View File
@@ -1,6 +1,7 @@
'use client';
import clsx from 'clsx';
import { useCallback } from 'react';
import {
Button,
type Key,
@@ -13,38 +14,63 @@ import {
type SelectProps,
SelectValue,
} from 'react-aria-components';
import { useStore } from 'zustand';
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
export type OpenAPISelectItem = {
key: Key;
label: string;
label: string | React.ReactNode;
};
interface OpenAPISelectProps<T extends OpenAPISelectItem> extends Omit<SelectProps<T>, 'children'> {
items: T[];
children: React.ReactNode | ((item: T) => React.ReactNode);
selectedKey?: Key;
onChange?: (key: string | number) => void;
placement?: PopoverProps['placement'];
stateKey?: string;
/**
* Icon to display in the select button.
*/
icon?: React.ReactNode;
}
export function useSelectState(stateKey = 'select-state', initialKey?: Key) {
const store = useStore(getOrCreateStoreByKey(stateKey, initialKey));
return {
key: store.key,
setKey: useCallback((key: Key | null) => store.setKey(key), [store.setKey]),
};
}
export function OpenAPISelect<T extends OpenAPISelectItem>(props: OpenAPISelectProps<T>) {
const { items, children, className, placement } = props;
const {
icon = '▼',
items,
children,
className,
placement,
stateKey,
selectedKey,
onSelectionChange,
} = props;
const state = useSelectState(stateKey, items[0]?.key);
const selected = items.find((item) => item.key === state.key) || items[0];
return (
<Select {...props} className={clsx('openapi-select', className)}>
<Select
aria-label="OpenAPI Select"
{...props}
selectedKey={selectedKey || selected?.key}
onSelectionChange={(key) => {
onSelectionChange?.(key);
state.setKey(key);
}}
className={clsx('openapi-select', className)}
>
<Button>
<SelectValue />
<span aria-hidden="true">
<svg
className="gb-icon"
style={{
maskImage:
"url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/chevron-down.svg?v=2&token=a463935e93')",
maskRepeat: 'no-repeat',
maskPosition: 'center center',
}}
/>
</span>
{icon}
</Button>
<Popover placement={placement} className="openapi-select-popover">
<ListBox className="openapi-select-listbox" items={items}>
@@ -42,6 +42,7 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
key="body"
requestBody={operation.requestBody}
context={context}
data={data}
/>
) : null}
{operation.responses ? (
+37 -5
View File
@@ -42,18 +42,50 @@ export const SectionBody = forwardRef(function SectionBody(
);
});
export function SectionFooter(props: ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
'openapi-section-footer',
props.className && `${props.className}-footer`
)}
/>
);
}
export function SectionFooterContent(props: ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
'openapi-section-footer-content',
props.className && `${props.className}-footer-content`
)}
/>
);
}
export function StaticSection(props: {
className: string;
header: React.ReactNode;
header?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
const { className, header, children } = props;
const { className, header, children, footer } = props;
return (
<Section className={className}>
<SectionHeader className={className}>
<SectionHeaderContent className={className}>{header}</SectionHeaderContent>
</SectionHeader>
{header ? (
<SectionHeader className={className}>
<SectionHeaderContent className={className}>{header}</SectionHeaderContent>
</SectionHeader>
) : null}
<SectionBody className={className}>{children}</SectionBody>
{footer ? (
<SectionFooter className={className}>
<SectionFooterContent className={className}>{footer}</SectionFooterContent>
</SectionFooter>
) : null}
</Section>
);
}
+46 -46
View File
@@ -26,6 +26,52 @@ export interface CodeSampleGenerator {
}
export const codeSampleGenerators: CodeSampleGenerator[] = [
{
id: 'http',
label: 'HTTP',
syntax: 'bash',
generate: ({ method, url, headers = {}, body }: CodeSampleInput) => {
const { host, path } = parseHostAndPath(url);
if (body) {
// if we had a body add a content length header
const bodyContent = body ? stringifyOpenAPI(body) : '';
// handle unicode chars with a text encoder
const encoder = new TextEncoder();
const bodyString = BodyGenerators.getHTTPBody(body, headers);
if (bodyString) {
body = bodyString;
}
headers = {
...headers,
'Content-Length': encoder.encode(bodyContent).length.toString(),
};
}
if (!headers.hasOwnProperty('Accept')) {
headers.Accept = '*/*';
}
const headerString = headers
? `${Object.entries(headers)
.map(([key, value]) =>
key.toLowerCase() !== 'host' ? `${key}: ${value}` : ''
)
.join('\n')}\n`
: '';
const bodyString = body ? `\n${body}` : '';
const httpRequest = `${method.toUpperCase()} ${decodeURI(path)} HTTP/1.1
Host: ${host}
${headerString}${bodyString}`;
return httpRequest;
},
},
{
id: 'curl',
label: 'cURL',
@@ -140,52 +186,6 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
return code;
},
},
{
id: 'http',
label: 'HTTP',
syntax: 'bash',
generate: ({ method, url, headers = {}, body }: CodeSampleInput) => {
const { host, path } = parseHostAndPath(url);
if (body) {
// if we had a body add a content length header
const bodyContent = body ? stringifyOpenAPI(body) : '';
// handle unicode chars with a text encoder
const encoder = new TextEncoder();
const bodyString = BodyGenerators.getHTTPBody(body, headers);
if (bodyString) {
body = bodyString;
}
headers = {
...headers,
'Content-Length': encoder.encode(bodyContent).length.toString(),
};
}
if (!headers.hasOwnProperty('Accept')) {
headers.Accept = '*/*';
}
const headerString = headers
? `${Object.entries(headers)
.map(([key, value]) =>
key.toLowerCase() !== 'host' ? `${key}: ${value}` : ''
)
.join('\n')}\n`
: '';
const bodyString = body ? `\n${body}` : '';
const httpRequest = `${method.toUpperCase()} ${decodeURI(path)} HTTP/1.1
Host: ${host}
${headerString}${bodyString}`;
return httpRequest;
},
},
];
function indent(code: string, spaces: number) {
@@ -1,5 +1,3 @@
'use client';
import { createStore } from 'zustand';
type Key = string | number;
@@ -10,7 +8,7 @@ type State = {
type Actions = { setKey: (key: Key | null) => void };
type Store = State & Actions;
export type Store = State & Actions;
const createStateStore = (initial?: Key) => {
return createStore<Store>()((set) => ({
@@ -1,6 +1,6 @@
import type { OpenAPISchema } from '@gitbook/openapi-parser';
import clsx from 'clsx';
import { OpenAPIDisclosureGroup } from '../OpenAPIDisclosureGroup';
import { OpenAPIDisclosure } from '../OpenAPIDisclosure';
import { OpenAPIExample, getExampleFromSchema } from '../OpenAPIExample';
import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
import { Section, SectionBody, StaticSection } from '../StaticSection';
@@ -72,33 +72,22 @@ export function OpenAPISchemas(props: {
// If there are multiple schemas, we use a disclosure group to show them all.
return (
<div className={clsx('openapi-schemas', className)}>
<OpenAPIDisclosureGroup
allowsMultipleExpanded
icon={context.icons.chevronRight}
groups={schemas.map(({ name, schema }) => ({
id: name,
label: (
<div className="openapi-response-tab-content" key={`model-${name}`}>
<span className="openapi-response-statuscode">{name}</span>
</div>
),
tabs: [
{
id: 'model',
body: (
<Section className="openapi-section-schemas">
<SectionBody>
<OpenAPIRootSchema
schema={schema}
context={clientContext}
/>
</SectionBody>
</Section>
),
},
],
}))}
/>
{schemas.map(({ name, schema }) => {
return (
<OpenAPIDisclosure
className="openapi-schemas-disclosure"
key={name}
icon={context.icons.chevronRight}
label={name}
>
<Section className="openapi-section-schemas">
<SectionBody>
<OpenAPIRootSchema schema={schema} context={clientContext} />
</SectionBody>
</Section>
</OpenAPIDisclosure>
);
})}
</div>
);
}
+62
View File
@@ -149,3 +149,65 @@ function shouldDisplayExample(schema: OpenAPIV3.SchemaObject): boolean {
Object.keys(schema.example).length > 0)
);
}
/**
* Get the class name for a status code.
* 1xx: informational
* 2xx: success
* 3xx: redirect
* 4xx, 5xx: error
*/
export function getStatusCodeClassName(statusCode: number | string): string {
const category = getStatusCodeCategory(statusCode);
switch (category) {
case 1:
return 'informational';
case 2:
return 'success';
case 3:
return 'redirect';
case 4:
case 5:
return 'error';
default:
return 'unknown';
}
}
/**
* Get a default label for a status code.
* This is used when there is no label provided in the OpenAPI spec.
* 1xx: Information
* 2xx: Success
* 3xx: Redirect
* 4xx, 5xx: Error
*/
export function getStatusCodeDefaultLabel(statusCode: number | string): string {
const category = getStatusCodeCategory(statusCode);
switch (category) {
case 1:
return 'Information';
case 2:
return 'Success';
case 3:
return 'Redirect';
case 4:
case 5:
return 'Error';
default:
return '';
}
}
function getStatusCodeCategory(statusCode: number | string): number | string {
const code = typeof statusCode === 'string' ? Number.parseInt(statusCode, 10) : statusCode;
if (Number.isNaN(code) || code < 100 || code >= 600) {
return 'unknown';
}
// Determine the category of the status code based on the first digit
const category = Math.floor(code / 100);
return category;
}