diff --git a/.changeset/good-dogs-shave.md b/.changeset/good-dogs-shave.md new file mode 100644 index 000000000..a1d11d2c9 --- /dev/null +++ b/.changeset/good-dogs-shave.md @@ -0,0 +1,7 @@ +--- +'@gitbook/openapi-parser': minor +'@gitbook/react-openapi': minor +'gitbook': minor +--- + +Implement OpenAPI models blocks diff --git a/.vscode/settings.json b/.vscode/settings.json index e9b4e7bfe..9e6f58505 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "prettier.enable": false, "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { - "source.organizeImports.biome": "explicit" + "source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" } } diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx index 86e9cd7d0..d1cf05d0b 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx @@ -2,7 +2,7 @@ import type { JSONDocument } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import { OpenAPIOperation } from '@gitbook/react-openapi'; -import { type AnyOpenAPIOperationBlock, resolveOpenAPIBlock } from '@/lib/openapi/fetch'; +import { resolveOpenAPIOperationBlock } from '@/lib/openapi/resolveOpenAPIOperationBlock'; import { tcls } from '@/lib/tailwind'; import type { BlockProps } from '../Block'; @@ -12,11 +12,12 @@ import { Heading } from '../Heading'; import './scalar.css'; import './style.css'; +import type { AnyOpenAPIBlock } from '@/lib/openapi/types'; /** * Render an openapi block or an openapi-operation block. */ -export async function OpenAPI(props: BlockProps) { +export async function OpenAPI(props: BlockProps) { const { style } = props; return (
@@ -25,14 +26,14 @@ export async function OpenAPI(props: BlockProps) { ); } -async function OpenAPIBody(props: BlockProps) { +async function OpenAPIBody(props: BlockProps) { const { block, context } = props; if (!context.contentContext) { return null; } - const { data, specUrl, error } = await resolveOpenAPIBlock({ + const { data, specUrl, error } = await resolveOpenAPIOperationBlock({ block, context: context.contentContext, }); diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index 62d90005b..c538c3de9 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -3,6 +3,10 @@ @apply flex-1 flex flex-col gap-4 mb-14; } +.openapi-models { + @apply flex flex-col mb-14 flex-1; +} + .openapi-columns { @apply grid grid-cols-1 lg:grid-cols-2 gap-6 print-mode:grid-cols-1 justify-stretch; } @@ -615,3 +619,11 @@ .openapi-section-body.openapi-schema.openapi-schema-root { @apply space-y-2.5; } + +.openapi-section-models { + @apply border border-tint-subtle rounded-lg; +} + +.openapi-section-models > .openapi-section-body > .openapi-schema-properties > .openapi-schema { + @apply p-2.5; +} diff --git a/packages/gitbook/src/lib/document-sections.ts b/packages/gitbook/src/lib/document-sections.ts index 0c9c0c8d2..de2fdaab2 100644 --- a/packages/gitbook/src/lib/document-sections.ts +++ b/packages/gitbook/src/lib/document-sections.ts @@ -2,7 +2,7 @@ import type { JSONDocument } from '@gitbook/api'; import type { GitBookAnyContext } from '@v2/lib/context'; import { getNodeText } from './document'; -import { resolveOpenAPIBlock } from './openapi/fetch'; +import { resolveOpenAPIOperationBlock } from './openapi/resolveOpenAPIOperationBlock'; export interface DocumentSection { id: string; @@ -38,7 +38,7 @@ export async function getDocumentSections( } if ((block.type === 'swagger' || block.type === 'openapi-operation') && block.meta?.id) { - const { data: operation } = await resolveOpenAPIBlock({ + const { data: operation } = await resolveOpenAPIOperationBlock({ block, context, }); diff --git a/packages/gitbook/src/lib/openapi/fetch.ts b/packages/gitbook/src/lib/openapi/fetch.ts index 655010a58..ef33dfaf6 100644 --- a/packages/gitbook/src/lib/openapi/fetch.ts +++ b/packages/gitbook/src/lib/openapi/fetch.ts @@ -1,82 +1,40 @@ -import type { DocumentBlockOpenAPI, DocumentBlockOpenAPIOperation } from '@gitbook/api'; -import { OpenAPIParseError, parseOpenAPI } from '@gitbook/openapi-parser'; -import { type OpenAPIOperationData, resolveOpenAPIOperation } from '@gitbook/react-openapi'; -import type { GitBookAnyContext } from '@v2/lib/context'; +import { parseOpenAPI } from '@gitbook/openapi-parser'; import { type CacheFunctionOptions, cache, noCacheFetchOptions } from '@/lib/cache'; - +import type { ResolveOpenAPIBlockArgs } from '@/lib/openapi/types'; import { assert } from 'ts-essentials'; import { resolveContentRef } from '../references'; import { isV2 } from '../v2'; import { enrichFilesystem } from './enrich'; - -export type AnyOpenAPIOperationBlock = DocumentBlockOpenAPI | DocumentBlockOpenAPIOperation; - -const weakmap = new WeakMap>(); +import type { FetchOpenAPIFilesystemResult } from './types'; /** - * Cache the result of resolving an OpenAPI block. - * It is important because the resolve is called in sections and in the block itself. + * Fetch OpenAPI block. */ -export function resolveOpenAPIBlock( +export async function fetchOpenAPIFilesystem( args: ResolveOpenAPIBlockArgs -): Promise { - if (weakmap.has(args.block)) { - return weakmap.get(args.block)!; - } - - const result = baseResolveOpenAPIBlock(args); - weakmap.set(args.block, result); - return result; -} - -type ResolveOpenAPIBlockArgs = { - block: AnyOpenAPIOperationBlock; - context: GitBookAnyContext; -}; -export type ResolveOpenAPIBlockResult = - | { error?: undefined; data: OpenAPIOperationData | null; specUrl: string | null } - | { error: OpenAPIParseError; data?: undefined; specUrl?: undefined }; -/** - * Resolve OpenAPI block. - */ -async function baseResolveOpenAPIBlock( - args: ResolveOpenAPIBlockArgs -): Promise { +): Promise { const { context, block } = args; - if (!block.data.path || !block.data.method) { - return { data: null, specUrl: null }; - } const ref = block.data.ref; const resolved = ref ? await resolveContentRef(ref, context) : null; if (!resolved) { - return { data: null, specUrl: null }; + return { filesystem: null, specUrl: null }; } - try { - const filesystem = await (() => { - if (ref.kind === 'openapi') { - assert(resolved.openAPIFilesystem); - return resolved.openAPIFilesystem; - } - return fetchFilesystem(resolved.href); - })(); - - const data = await resolveOpenAPIOperation(filesystem, { - path: block.data.path, - method: block.data.method, - }); - - return { data, specUrl: resolved.href }; - } catch (error) { - if (error instanceof OpenAPIParseError) { - return { error }; + const filesystem = await (() => { + if (ref.kind === 'openapi') { + assert(resolved.openAPIFilesystem); + return resolved.openAPIFilesystem; } + return fetchFilesystem(resolved.href); + })(); - throw error; - } + return { + filesystem, + specUrl: resolved.href, + }; } function fetchFilesystem(url: string) { diff --git a/packages/gitbook/src/lib/openapi/resolveOpenAPIModelsBlock.ts b/packages/gitbook/src/lib/openapi/resolveOpenAPIModelsBlock.ts new file mode 100644 index 000000000..6c89e2025 --- /dev/null +++ b/packages/gitbook/src/lib/openapi/resolveOpenAPIModelsBlock.ts @@ -0,0 +1,55 @@ +import { fetchOpenAPIFilesystem } from '@/lib/openapi/fetch'; +import type { ResolveOpenAPIBlockResult } from '@/lib/openapi/types'; +import { OpenAPIParseError } from '@gitbook/openapi-parser'; +import { type OpenAPIModelsData, resolveOpenAPIModels } from '@gitbook/react-openapi'; +import type { AnyOpenAPIBlock, ResolveOpenAPIBlockArgs } from './types'; + +type ResolveOpenAPIModelsBlockResult = ResolveOpenAPIBlockResult; + +const weakmap = new WeakMap>(); + +/** + * Cache the result of resolving an OpenAPI block. + * It is important because the resolve is called in sections and in the block itself. + */ +export function resolveOpenAPIModelsBlock( + args: ResolveOpenAPIBlockArgs +): Promise { + if (weakmap.has(args.block)) { + return weakmap.get(args.block)!; + } + + const result = baseResolveOpenAPIModelsBlock(args); + weakmap.set(args.block, result); + return result; +} + +/** + * Resolve OpenAPI models block. + */ +async function baseResolveOpenAPIModelsBlock( + args: ResolveOpenAPIBlockArgs +): Promise { + const { context, block } = args; + if (!block.data.path || !block.data.method) { + return { data: null, specUrl: null }; + } + + try { + const { filesystem, specUrl } = await fetchOpenAPIFilesystem({ block, context }); + + if (!filesystem || !specUrl) { + return { data: null, specUrl: null }; + } + + const data = await resolveOpenAPIModels(filesystem); + + return { data, specUrl }; + } catch (error) { + if (error instanceof OpenAPIParseError) { + return { error }; + } + + throw error; + } +} diff --git a/packages/gitbook/src/lib/openapi/resolveOpenAPIOperationBlock.ts b/packages/gitbook/src/lib/openapi/resolveOpenAPIOperationBlock.ts new file mode 100644 index 000000000..b7b753606 --- /dev/null +++ b/packages/gitbook/src/lib/openapi/resolveOpenAPIOperationBlock.ts @@ -0,0 +1,57 @@ +import { fetchOpenAPIFilesystem } from '@/lib/openapi/fetch'; +import { OpenAPIParseError } from '@gitbook/openapi-parser'; +import { type OpenAPIOperationData, resolveOpenAPIOperation } from '@gitbook/react-openapi'; +import type { AnyOpenAPIBlock, ResolveOpenAPIBlockArgs, ResolveOpenAPIBlockResult } from './types'; + +type ResolveOpenAPIOperationBlockResult = ResolveOpenAPIBlockResult; + +const weakmap = new WeakMap>(); + +/** + * Cache the result of resolving an OpenAPI block. + * It is important because the resolve is called in sections and in the block itself. + */ +export function resolveOpenAPIOperationBlock( + args: ResolveOpenAPIBlockArgs +): Promise { + if (weakmap.has(args.block)) { + return weakmap.get(args.block)!; + } + + const result = baseResolveOpenAPIOperationBlock(args); + weakmap.set(args.block, result); + return result; +} + +/** + * Resolve OpenAPI operation block. + */ +async function baseResolveOpenAPIOperationBlock( + args: ResolveOpenAPIBlockArgs +): Promise { + const { context, block } = args; + if (!block.data.path || !block.data.method) { + return { data: null, specUrl: null }; + } + + try { + const { filesystem, specUrl } = await fetchOpenAPIFilesystem({ block, context }); + + if (!filesystem) { + return { data: null, specUrl: null }; + } + + const data = await resolveOpenAPIOperation(filesystem, { + path: block.data.path, + method: block.data.method, + }); + + return { data, specUrl }; + } catch (error) { + if (error instanceof OpenAPIParseError) { + return { error }; + } + + throw error; + } +} diff --git a/packages/gitbook/src/lib/openapi/types.ts b/packages/gitbook/src/lib/openapi/types.ts new file mode 100644 index 000000000..336c08aa9 --- /dev/null +++ b/packages/gitbook/src/lib/openapi/types.ts @@ -0,0 +1,50 @@ +import type { DocumentBlockOpenAPI, DocumentBlockOpenAPIOperation } from '@gitbook/api'; +import type { Filesystem, OpenAPIParseError, OpenAPIV3xDocument } from '@gitbook/openapi-parser'; +import type { GitBookAnyContext } from '@v2/lib/context'; + +//!!TODO: Add DocumentBlockOpenAPIModels when available in @gitbook/api +export type AnyOpenAPIBlock = DocumentBlockOpenAPI | DocumentBlockOpenAPIOperation; + +/** + * Arguments for resolving OpenAPI block. + */ +export type ResolveOpenAPIBlockArgs = { + block: AnyOpenAPIBlock; + context: GitBookAnyContext; +}; + +/** + * Fetch OpenAPI filesystem result. + */ +export type FetchOpenAPIFilesystemResult = + | { + error?: undefined; + filesystem: Filesystem | null; + specUrl: string | null; + } + | FetchOpenAPIFilesystemError; + +/** + * Fetch OpenAPI filesystem error. + */ +type FetchOpenAPIFilesystemError = { + error: OpenAPIParseError; + filesystem?: undefined; + specUrl?: undefined; +}; + +/** + * Resolved OpenAPI block result. + */ +export type ResolveOpenAPIBlockResult = + | { error?: undefined; data: T | null; specUrl: string | null } + | ResolveOpenAPIBlockError; + +/** + * Resolved OpenAPI block error. + */ +type ResolveOpenAPIBlockError = { + error: OpenAPIParseError; + data?: undefined; + specUrl?: undefined; +}; diff --git a/packages/openapi-parser/src/traverse.ts b/packages/openapi-parser/src/traverse.ts index 347a015c2..131d7b0ed 100644 --- a/packages/openapi-parser/src/traverse.ts +++ b/packages/openapi-parser/src/traverse.ts @@ -29,12 +29,20 @@ export async function traverse( } const keys = Object.keys(specification); - await Promise.all( - keys.map(async (key) => { + const results = await Promise.all( + keys.map(async (key, index) => { const value = specification[key]; - result[key] = await traverse(value, transform, [...path, key], seen); + const processed = await traverse(value, transform, [...path, key], seen); + return { key, value: processed, index }; }) ); + // Promise.all does not guarantee the order of the results + // So we need to sort them to preserve the original order + results.sort((a, b) => a.index - b.index); + for (const { key, value } of results) { + result[key] = value; + } + return transform(result, path) as Promise; } diff --git a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx index 37bc364d8..82c89c28a 100644 --- a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx @@ -19,7 +19,7 @@ type TDisclosureGroup = { label: string | React.ReactNode; tabs?: { id: string; - label: string | React.ReactNode; + label?: string | React.ReactNode; body?: React.ReactNode; }[]; }; @@ -121,7 +121,7 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode ))} - ) : group.tabs[0] ? ( + ) : group.tabs[0]?.label ? ( {group.tabs[0].label} ) : null}
diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index 33f1f9d68..31cddaa0b 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -5,9 +5,8 @@ import clsx from 'clsx'; import { Markdown } from './Markdown'; import { OpenAPIDisclosure } from './OpenAPIDisclosure'; import { OpenAPISchemaName } from './OpenAPISchemaName'; -import { stringifyOpenAPI } from './stringifyOpenAPI'; import type { OpenAPIClientContext } from './types'; -import { checkIsReference, resolveDescription } from './utils'; +import { checkIsReference, resolveDescription, resolveFirstExample } from './utils'; type CircularRefsIds = Map; @@ -181,7 +180,7 @@ function OpenAPISchemaCircularRef(props: { id: string; schema: OpenAPIV3.SchemaO /** * Render the enum value for a schema. */ -export function OpenAPISchemaEnum(props: { enumValues: any[] }) { +function OpenAPISchemaEnum(props: { enumValues: any[] }) { const { enumValues } = props; return ( @@ -199,24 +198,16 @@ export function OpenAPISchemaEnum(props: { enumValues: any[] }) { ); } -export function OpenAPISchemaPresentation(props: { property: OpenAPISchemaPropertyEntry }) { +/** + * Render the top row of a schema. e.g: name, type, and required status. + */ +function OpenAPISchemaPresentation(props: { property: OpenAPISchemaPropertyEntry }) { const { property: { schema, propertyName, required }, } = props; - const shouldDisplayExample = (schema: OpenAPIV3.SchemaObject): boolean => { - return ( - (typeof schema.example === 'string' && !!schema.example) || - typeof schema.example === 'number' || - typeof schema.example === 'boolean' || - (Array.isArray(schema.example) && schema.example.length > 0) || - (typeof schema.example === 'object' && - schema.example !== null && - Object.keys(schema.example).length > 0) - ); - }; - const description = resolveDescription(schema); + const example = resolveFirstExample(schema); return (
@@ -237,9 +228,9 @@ export function OpenAPISchemaPresentation(props: { property: OpenAPISchemaProper {description ? ( ) : null} - {shouldDisplayExample(schema) ? ( + {example ? (
- Example: {formatExample(schema.example)} + Example: {example}
) : null} {schema.pattern ? ( @@ -370,7 +361,7 @@ function flattenAlternatives( }, []); } -export function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string { +function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string { // Otherwise try to infer a nice title let type = 'any'; @@ -418,16 +409,3 @@ function getDisclosureLabel(schema: OpenAPIV3.SchemaObject): string { return schema.title || 'child attributes'; } - -function formatExample(example: any): string { - if (typeof example === 'string') { - return example - .replace(/\n/g, ' ') // Replace newlines with spaces - .replace(/\s+/g, ' ') // Collapse multiple spaces/newlines into a single space - .replace(/([\{\}:,])\s+/g, '$1 ') // Ensure a space after {, }, :, and , - .replace(/\s+([\{\}:,])/g, ' $1') // Ensure a space before {, }, :, and , - .trim(); - } - - return stringifyOpenAPI(example); -} diff --git a/packages/react-openapi/src/dereference.ts b/packages/react-openapi/src/dereference.ts new file mode 100644 index 000000000..dc772f4cd --- /dev/null +++ b/packages/react-openapi/src/dereference.ts @@ -0,0 +1,29 @@ +import { type Filesystem, type OpenAPIV3xDocument, dereference } from '@gitbook/openapi-parser'; + +const dereferenceCache = new WeakMap>(); + +/** + * Memoized version of `dereferenceSchema`. + */ +export function dereferenceFilesystem(filesystem: Filesystem): Promise { + if (dereferenceCache.has(filesystem)) { + return dereferenceCache.get(filesystem) as Promise; + } + + const promise = baseDereferenceFilesystem(filesystem); + dereferenceCache.set(filesystem, promise); + return promise; +} + +/** + * Dereference an OpenAPI schema. + */ +async function baseDereferenceFilesystem(filesystem: Filesystem): Promise { + const result = await dereference(filesystem); + + if (!result.schema) { + throw new Error('Failed to dereference OpenAPI document'); + } + + return result.schema as OpenAPIV3xDocument; +} diff --git a/packages/react-openapi/src/index.ts b/packages/react-openapi/src/index.ts index 9d8d0b47a..832ff0684 100644 --- a/packages/react-openapi/src/index.ts +++ b/packages/react-openapi/src/index.ts @@ -1,4 +1,5 @@ -export * from './resolveOpenAPIOperation'; +export * from './models'; export * from './OpenAPIOperation'; export * from './OpenAPIOperationContext'; -export type { OpenAPIOperationData } from './types'; +export * from './resolveOpenAPIOperation'; +export type { OpenAPIModelsData, OpenAPIOperationData } from './types'; diff --git a/packages/react-openapi/src/models/OpenAPIModels.tsx b/packages/react-openapi/src/models/OpenAPIModels.tsx new file mode 100644 index 000000000..2e9030a16 --- /dev/null +++ b/packages/react-openapi/src/models/OpenAPIModels.tsx @@ -0,0 +1,89 @@ +import clsx from 'clsx'; +import { OpenAPIDisclosureGroup } from '../OpenAPIDisclosureGroup'; +import { OpenAPIRootSchema } from '../OpenAPISchema'; +import { Section, SectionBody } from '../StaticSection'; +import type { OpenAPIClientContext, OpenAPIContextProps, OpenAPIModelsData } from '../types'; + +/** + * Display OpenAPI Models. + */ +export function OpenAPIModels(props: { + className?: string; + data: OpenAPIModelsData; + context: OpenAPIContextProps; +}) { + const { className, data, context } = props; + const { models } = data; + + const clientContext: OpenAPIClientContext = { + defaultInteractiveOpened: context.defaultInteractiveOpened, + icons: context.icons, + blockKey: context.blockKey, + }; + + if (!models.length) { + return null; + } + + return ( +
+ +
+ ); +} + +/** + * Root schema for OpenAPI models. + * It displays a single model or a disclosure group for multiple models. + */ +function OpenAPIRootModelsSchema(props: { + models: OpenAPIModelsData['models']; + context: OpenAPIClientContext; +}) { + const { models, context } = props; + + // If there is only one model, we show it directly. + if (models.length === 1) { + const schema = models?.[0]?.schema; + + if (!schema) { + return null; + } + + return ( +
+ + + +
+ ); + } + + // If there are multiple models, we use a disclosure group to show them all. + return ( + ({ + id: name, + label: ( +
+ {name} +
+ ), + tabs: [ + { + id: 'model', + body: ( +
+ + + +
+ ), + }, + ], + }))} + /> + ); +} diff --git a/packages/react-openapi/src/models/index.ts b/packages/react-openapi/src/models/index.ts new file mode 100644 index 000000000..88dca43d6 --- /dev/null +++ b/packages/react-openapi/src/models/index.ts @@ -0,0 +1,2 @@ +export * from './OpenAPIModels'; +export * from './resolveOpenAPIModels'; diff --git a/packages/react-openapi/src/models/resolveOpenAPIModels.ts b/packages/react-openapi/src/models/resolveOpenAPIModels.ts new file mode 100644 index 000000000..5f9d8d878 --- /dev/null +++ b/packages/react-openapi/src/models/resolveOpenAPIModels.ts @@ -0,0 +1,35 @@ +import { + type Filesystem, + type OpenAPIV3, + type OpenAPIV3_1, + type OpenAPIV3xDocument, + shouldIgnoreEntity, +} from '@gitbook/openapi-parser'; +import { dereferenceFilesystem } from '../dereference'; +import type { OpenAPIModel, OpenAPIModelsData } from '../types'; + +//!!TODO: We should return only the models that are used in the block. Still a WIP awaiting future work. + +/** + * Resolve an OpenAPI models from a file and compile it to a more usable format. + * Models are extracted from the OpenAPI components.schemas + */ +export async function resolveOpenAPIModels( + filesystem: Filesystem +): Promise { + const schema = await dereferenceFilesystem(filesystem); + + const models = getOpenAPIComponents(schema); + + return { models }; +} + +/** + * Get OpenAPI components.schemas that are not ignored. + */ +function getOpenAPIComponents(schema: OpenAPIV3.Document | OpenAPIV3_1.Document): OpenAPIModel[] { + const schemas = schema.components?.schemas ?? {}; + return Object.entries(schemas) + .filter(([, schema]) => !shouldIgnoreEntity(schema)) + .map(([key, schema]) => ({ name: key, schema })); +} diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.ts b/packages/react-openapi/src/resolveOpenAPIOperation.ts index f3044176e..295f2b79c 100644 --- a/packages/react-openapi/src/resolveOpenAPIOperation.ts +++ b/packages/react-openapi/src/resolveOpenAPIOperation.ts @@ -1,16 +1,16 @@ import { fromJSON, toJSON } from 'flatted'; -import { - type Filesystem, - type OpenAPIV3, - type OpenAPIV3_1, - type OpenAPIV3xDocument, - dereference, +import type { + Filesystem, + OpenAPIV3, + OpenAPIV3_1, + OpenAPIV3xDocument, } from '@gitbook/openapi-parser'; +import { dereferenceFilesystem } from './dereference'; import type { OpenAPIOperationData } from './types'; import { checkIsReference } from './utils'; -export { toJSON, fromJSON }; +export { fromJSON, toJSON }; /** * Resolve an OpenAPI operation in a file and compile it to a more usable format. @@ -23,7 +23,7 @@ export async function resolveOpenAPIOperation( } ): Promise { const { path, method } = operationDescriptor; - const schema = await memoDereferenceFilesystem(filesystem); + const schema = await dereferenceFilesystem(filesystem); let operation = getOperationByPathAndMethod(schema, path, method); if (!operation) { @@ -69,34 +69,6 @@ export async function resolveOpenAPIOperation( }; } -const dereferenceCache = new WeakMap>(); - -/** - * Memoized version of `dereferenceSchema`. - */ -function memoDereferenceFilesystem(filesystem: Filesystem): Promise { - if (dereferenceCache.has(filesystem)) { - return dereferenceCache.get(filesystem) as Promise; - } - - const promise = dereferenceFilesystem(filesystem); - dereferenceCache.set(filesystem, promise); - return promise; -} - -/** - * Dereference an OpenAPI schema. - */ -async function dereferenceFilesystem(filesystem: Filesystem): Promise { - const result = await dereference(filesystem); - - if (!result.schema) { - throw new Error('Failed to dereference OpenAPI document'); - } - - return result.schema as OpenAPIV3xDocument; -} - /** * Get a path object from its path. */ diff --git a/packages/react-openapi/src/types.ts b/packages/react-openapi/src/types.ts index d652b11b0..fa7f5bf8c 100644 --- a/packages/react-openapi/src/types.ts +++ b/packages/react-openapi/src/types.ts @@ -55,3 +55,13 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties { /** Securities that should be used for this operation */ securities: [string, OpenAPIV3.SecuritySchemeObject][]; } + +export type OpenAPIModel = { + name: string; + schema: OpenAPIV3.SchemaObject; +}; + +export interface OpenAPIModelsData { + /** Components schemas to be used for models */ + models: OpenAPIModel[]; +} diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index e28656d10..a9fd74f13 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -1,4 +1,5 @@ import type { AnyObject, OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser'; +import { stringifyOpenAPI } from './stringifyOpenAPI'; export function checkIsReference( input: unknown @@ -10,11 +11,19 @@ export function createStateKey(key: string, scope?: string) { return scope ? `${scope}_${key}` : key; } +/** + * Check if an object has a description. Either at the root level or in items. + */ +function hasDescription(object: AnyObject) { + return 'description' in object || 'x-gitbook-description-html' in object; +} + /** * Resolve the description of an object. */ export function resolveDescription(object: OpenAPIV3.SchemaObject | AnyObject) { - if ('items' in object && object.items) { + // If the object has items and has a description, we resolve the description from items + if ('items' in object && typeof object.items === 'object' && hasDescription(object.items)) { return resolveDescription(object.items); } @@ -50,9 +59,17 @@ export function resolveFirstExample(object: AnyObject) { return object.examples[firstKey]; } } - if ('example' in object && object.example !== undefined) { - return object.example; + + // Resolve top level example first + if (shouldDisplayExample(object)) { + return formatExample(object.example); } + + // Resolve example from items if it exists + if (object.items && typeof object.items === 'object') { + return formatExample(object.items.example); + } + return undefined; } @@ -98,3 +115,34 @@ export function parameterToProperty( required: parameter.required, }; } + +/** + * Format the example of a schema. + */ +function formatExample(example: unknown): string { + if (typeof example === 'string') { + return example + .replace(/\n/g, ' ') // Replace newlines with spaces + .replace(/\s+/g, ' ') // Collapse multiple spaces/newlines into a single space + .replace(/([\{\}:,])\s+/g, '$1 ') // Ensure a space after {, }, :, and , + .replace(/\s+([\{\}:,])/g, ' $1') // Ensure a space before {, }, :, and , + .trim(); + } + + return stringifyOpenAPI(example); +} + +/** + * Check if an example should be displayed. + */ +function shouldDisplayExample(schema: OpenAPIV3.SchemaObject): boolean { + return ( + (typeof schema.example === 'string' && !!schema.example) || + typeof schema.example === 'number' || + typeof schema.example === 'boolean' || + (Array.isArray(schema.example) && schema.example.length > 0) || + (typeof schema.example === 'object' && + schema.example !== null && + Object.keys(schema.example).length > 0) + ); +}