Add support for new OpenAPI ref (#2860)

This commit is contained in:
Greg Bergé
2025-02-20 14:31:04 +01:00
committed by GitHub
parent a820739bd2
commit 9f0de74caa
10 changed files with 169 additions and 19 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Add support for new OpenAPI ref
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix ID not set when there is no operation summary
+3 -1
View File
@@ -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=="],
+1 -1
View File
@@ -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:*",
@@ -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<DocumentBlockOpenAPI>) {
async function OpenAPIBody(props: BlockProps<DocumentBlockOpenAPI>) {
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 (
<div className={tcls('hidden')}>
<div className="hidden">
<p>
Error with {error.rootURL}: {error.message}
Error with {specUrl}: {error.message}
</p>
</div>
);
+80
View File
@@ -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);
}
@@ -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,
+35 -10
View File
@@ -7,23 +7,48 @@ import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
import { enrichFilesystem } from './enrich';
import { ResolvedContentRef } from '../references';
const weakmap = new WeakMap<DocumentBlockOpenAPI, ResolveOpenAPIBlockResult>();
/**
* 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<ResolvedContentRef | null>,
): 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<ResolvedContentRef | null> };
};
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,
+26
View File
@@ -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);
}
@@ -29,7 +29,7 @@ export function OpenAPIOperation(props: {
return (
<div className={clsx('openapi-operation', className)}>
<div className="openapi-summary">
<div className="openapi-summary" id={operation.summary ? undefined : context.id}>
{operation.summary
? context.renderHeading({
deprecated: operation.deprecated ?? false,