diff --git a/.changeset/perfect-donuts-hear.md b/.changeset/perfect-donuts-hear.md new file mode 100644 index 000000000..88b99f7ed --- /dev/null +++ b/.changeset/perfect-donuts-hear.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Add support for new OpenAPI ref diff --git a/.changeset/poor-rats-trade.md b/.changeset/poor-rats-trade.md new file mode 100644 index 000000000..b60a578a5 --- /dev/null +++ b/.changeset/poor-rats-trade.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Fix ID not set when there is no operation summary diff --git a/bun.lock b/bun.lock index e5a4a5029..7f6e7b7ae 100644 --- a/bun.lock +++ b/bun.lock @@ -40,7 +40,7 @@ "name": "gitbook", "version": "0.6.1", "dependencies": { - "@gitbook/api": "^0.93.0", + "@gitbook/api": "^0.94.0", "@gitbook/cache-do": "workspace:*", "@gitbook/colors": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", @@ -4651,6 +4651,8 @@ "gaxios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "gitbook/@gitbook/api": ["@gitbook/api@0.94.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-jOvqUSdyXeuPpBiujkQLb14uVQA5A0XL+P89MmC/53hV7v/8gR8WlJN9RJVDrP0LX51dsLT+/zYN8xWp19nPwA=="], + "gitbook-v2/next": ["next@15.2.0-canary.45", "", { "dependencies": { "@next/env": "15.2.0-canary.45", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.2.0-canary.45", "@next/swc-darwin-x64": "15.2.0-canary.45", "@next/swc-linux-arm64-gnu": "15.2.0-canary.45", "@next/swc-linux-arm64-musl": "15.2.0-canary.45", "@next/swc-linux-x64-gnu": "15.2.0-canary.45", "@next/swc-linux-x64-musl": "15.2.0-canary.45", "@next/swc-win32-arm64-msvc": "15.2.0-canary.45", "@next/swc-win32-x64-msvc": "15.2.0-canary.45", "sharp": "^0.33.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-UsneTQn9tntbiAaXpvoXhhsTBb58Q2XIs2Dfka+qWA8motBz0ZvW297YHLxhdur4xN0IJvknnZKl5Bs7wAGlOg=="], "glob/minimatch": ["minimatch@10.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 76aff7473..8d6ead3d3 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -17,7 +17,7 @@ "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math" }, "dependencies": { - "@gitbook/api": "^0.93.0", + "@gitbook/api": "^0.94.0", "@gitbook/cache-do": "workspace:*", "@gitbook/colors": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx index ff50d6cc8..ddae7d6d8 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx @@ -3,7 +3,7 @@ import { Icon } from '@gitbook/icons'; import { OpenAPIOperation } from '@gitbook/react-openapi'; import React from 'react'; -import { fetchOpenAPIBlock } from '@/lib/openapi/fetch'; +import { resolveOpenAPIBlock } from '@/lib/openapi/fetch'; import { tcls } from '@/lib/tailwind'; import { BlockProps } from '../Block'; @@ -27,13 +27,17 @@ export async function OpenAPI(props: BlockProps) { async function OpenAPIBody(props: BlockProps) { const { block, context } = props; - const { data, specUrl, error } = await fetchOpenAPIBlock(block, context.resolveContentRef); + + const { data, specUrl, error } = await resolveOpenAPIBlock({ + block, + context: { resolveContentRef: context.resolveContentRef }, + }); if (error) { return ( -
+

- Error with {error.rootURL}: {error.message} + Error with {specUrl}: {error.message}

); diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index 04f500601..9f4019d58 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -208,6 +208,78 @@ export const getUserById = cache({ }, }); +/** + * Get the latest version of an OpenAPI spec by its slug. + */ +export const getLatestOpenAPISpecVersion = cache({ + name: 'api.getLatestOpenApiSpecVersion', + tag: (organization, openAPISpec) => + getAPICacheTag({ + tag: 'openapi', + organization, + openAPISpec, + }), + get: async (organizationId: string, slug: string, options: CacheFunctionOptions) => { + try { + const apiCtx = await api(); + const response = await apiCtx.client.orgs.getLatestOpenApiSpecVersion( + organizationId, + slug, + { + ...noCacheFetchOptions, + signal: options.signal, + }, + ); + return cacheResponse(response, { revalidateBefore: 60 * 60 }); + } catch (error) { + if (checkHasErrorCode(error, 404)) { + return { + revalidateBefore: 5, + data: null, + }; + } + + throw error; + } + }, +}); + +/** + * Get the latest version of an OpenAPI spec by its slug. + */ +export const getLatestOpenAPISpecVersionContent = cache({ + name: 'api.getLatestOpenApiSpecVersionContent', + tag: (organization, openAPISpec) => + getAPICacheTag({ + tag: 'openapi', + organization, + openAPISpec, + }), + get: async (organizationId: string, slug: string, options: CacheFunctionOptions) => { + try { + const apiCtx = await api(); + const response = await apiCtx.client.orgs.getLatestOpenApiSpecVersionContent( + organizationId, + slug, + { + ...noCacheFetchOptions, + signal: options.signal, + }, + ); + return cacheResponse(response, { revalidateBefore: 60 * 60 }); + } catch (error) { + if (checkHasErrorCode(error, 404)) { + return { + revalidateBefore: 5, + data: null, + }; + } + + throw error; + } + }, +}); + /** * Resolve a URL to the content to render. */ @@ -1226,6 +1298,12 @@ export function getAPICacheTag( | { tag: 'site'; site: string; + } + // All data related to an OpenAPI spec + | { + tag: 'openapi'; + organization: string; + openAPISpec: string; }, ): string { switch (spec.tag) { @@ -1249,6 +1327,8 @@ export function getAPICacheTag( return `site:${spec.site}`; case 'integration': return `integration:${spec.integration}`; + case 'openapi': + return `organization:${spec.organization}:openapi:${spec.openAPISpec}`; default: assertNever(spec); } diff --git a/packages/gitbook/src/lib/document-sections.ts b/packages/gitbook/src/lib/document-sections.ts index 375b5812d..71bf0239d 100644 --- a/packages/gitbook/src/lib/document-sections.ts +++ b/packages/gitbook/src/lib/document-sections.ts @@ -1,7 +1,7 @@ import { JSONDocument, ContentRef } from '@gitbook/api'; import { getNodeText } from './document'; -import { fetchOpenAPIBlock } from './openapi/fetch'; +import { resolveOpenAPIBlock } from './openapi/fetch'; import { ResolvedContentRef } from './references'; export interface DocumentSection { @@ -38,7 +38,10 @@ export async function getDocumentSections( } if (block.type === 'swagger' && block.meta?.id) { - const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef); + const { data: operation } = await resolveOpenAPIBlock({ + block, + context: { resolveContentRef }, + }); if (operation) { sections.push({ id: block.meta.id, diff --git a/packages/gitbook/src/lib/openapi/fetch.ts b/packages/gitbook/src/lib/openapi/fetch.ts index afb5f2f5b..af6443c87 100644 --- a/packages/gitbook/src/lib/openapi/fetch.ts +++ b/packages/gitbook/src/lib/openapi/fetch.ts @@ -7,23 +7,48 @@ import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache'; import { enrichFilesystem } from './enrich'; import { ResolvedContentRef } from '../references'; +const weakmap = new WeakMap(); + /** - * Fetch an OpenAPI specification for an operation. + * Cache the result of resolving an OpenAPI block. + * It is important because the resolve is called in sections and in the block itself. */ -export async function fetchOpenAPIBlock( - block: DocumentBlockOpenAPI, - resolveContentRef: (ref: ContentRef) => Promise, -): Promise< - | { data: OpenAPIOperationData | null; specUrl: string | null; error?: undefined } +export function resolveOpenAPIBlock(args: ResolveOpenAPIBlockArgs): ResolveOpenAPIBlockResult { + if (weakmap.has(args.block)) { + return weakmap.get(args.block)!; + } + + const result = baseResolveOpenAPIBlock(args); + weakmap.set(args.block, result); + return result; +} + +type ResolveOpenAPIBlockArgs = { + block: DocumentBlockOpenAPI; + context: { resolveContentRef: (ref: ContentRef) => Promise }; +}; +type ResolveOpenAPIBlockResult = Promise< + | { error?: undefined; data: OpenAPIOperationData | null; specUrl: string | null } | { error: OpenAPIParseError; data?: undefined; specUrl?: undefined } -> { - const resolved = block.data.ref ? await resolveContentRef(block.data.ref) : null; - if (!resolved || !block.data.path || !block.data.method) { +>; +/** + * Resolve OpenAPI block. + */ +async function baseResolveOpenAPIBlock(args: ResolveOpenAPIBlockArgs): ResolveOpenAPIBlockResult { + const { context, block } = args; + if (!block.data.path || !block.data.method) { + return { data: null, specUrl: null }; + } + + const resolved = block.data.ref ? await context.resolveContentRef(block.data.ref) : null; + + if (!resolved) { return { data: null, specUrl: null }; } try { - const filesystem = await fetchFilesystem(resolved.href); + const filesystem = resolved.openAPIFilesystem ?? (await fetchFilesystem(resolved.href)); + const data = await resolveOpenAPIOperation(filesystem, { path: block.data.path, method: block.data.method, diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index 6f1a0d2ed..b6b76fd90 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -7,6 +7,7 @@ import { SiteSpace, Space, } from '@gitbook/api'; +import type { Filesystem } from '@gitbook/openapi-parser'; import assertNever from 'assert-never'; import React from 'react'; @@ -17,6 +18,8 @@ import { SpaceContentPointer, getCollection, getDocument, + getLatestOpenAPISpecVersion, + getLatestOpenAPISpecVersionContent, getPageDocument, getPublishedContentSite, getReusableContent, @@ -50,6 +53,8 @@ export interface ResolvedContentRef { file?: RevisionFile; /** Resolved reusable content, if the ref points to reusable content on a revision. */ reusableContent?: RevisionReusableContent; + /** Resolve OpenAPI spec filesystem. */ + openAPIFilesystem?: Filesystem; } export interface ContentRefContext extends PageHrefContext { @@ -272,6 +277,27 @@ export async function resolveContentRef( }; } + case 'openapi': { + if (!siteContext) { + return null; + } + const { organizationId } = siteContext; + const [openAPISpecVersion, openAPISpecVersionContent] = await Promise.all([ + getLatestOpenAPISpecVersion(organizationId, contentRef.spec), + getLatestOpenAPISpecVersionContent(organizationId, contentRef.spec), + ]); + + if (!openAPISpecVersion || !openAPISpecVersionContent) { + return null; + } + return { + href: openAPISpecVersion.url, + text: contentRef.spec, + active: false, + openAPIFilesystem: openAPISpecVersionContent as Filesystem, + }; + } + default: assertNever(contentRef); } diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx index e1712a33b..47fd22d46 100644 --- a/packages/react-openapi/src/OpenAPIOperation.tsx +++ b/packages/react-openapi/src/OpenAPIOperation.tsx @@ -29,7 +29,7 @@ export function OpenAPIOperation(props: { return (
-
+
{operation.summary ? context.renderHeading({ deprecated: operation.deprecated ?? false,