From 03b74c2c24e2c576acdc3c2d18ee746c868fc776 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 12 Aug 2026 10:36:08 -0700 Subject: [PATCH] fix(docs): include API key header in generated code samples --- apps/docs/app/[lang]/[[...slug]]/page.tsx | 3 +- apps/docs/lib/openapi-code-samples-client.ts | 37 +++++++ apps/docs/lib/openapi-code-samples.ts | 21 ++++ apps/docs/lib/openapi.ts | 106 +++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 apps/docs/lib/openapi-code-samples-client.ts create mode 100644 apps/docs/lib/openapi-code-samples.ts diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 36c31389949..a4ffafdad87 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -16,7 +16,7 @@ import { CodeBlock } from '@/components/ui/code-block' import { Heading } from '@/components/ui/heading' import { ResponseSection } from '@/components/ui/response-section' import { i18n } from '@/lib/i18n' -import { getApiSpecContent, openapi } from '@/lib/openapi' +import { getApiSpecContent, getAuthenticatedCodeSamples, openapi } from '@/lib/openapi' import { type PageData, source } from '@/lib/source' import { DOCS_BASE_URL } from '@/lib/urls' @@ -71,6 +71,7 @@ function stripLocalePrefix(url: string, lang: string): string { const APIPage = createAPIPage(openapi, { playground: { enabled: false }, + generateCodeSamples: getAuthenticatedCodeSamples, client: { operation: { APIExampleSelector }, }, diff --git a/apps/docs/lib/openapi-code-samples-client.ts b/apps/docs/lib/openapi-code-samples-client.ts new file mode 100644 index 00000000000..f983da91e43 --- /dev/null +++ b/apps/docs/lib/openapi-code-samples-client.ts @@ -0,0 +1,37 @@ +'use client' + +import type { CodeUsageGeneratorFn } from 'fumadocs-openapi/requests/generators' +import { createCodeUsageGeneratorRegistry } from 'fumadocs-openapi/requests/generators' +import { registerDefault } from 'fumadocs-openapi/requests/generators/all' + +/** + * Context handed to {@link generateWithAuth} by the server: which built-in + * generator to delegate to, and the auth headers the sample must send. + */ +export interface AuthCodeSampleContext { + generatorId: string + headers: Record +} + +const generators = createCodeUsageGeneratorRegistry() +registerDefault(generators) + +/** + * Wraps a built-in code-usage generator so the sample carries the operation's + * security headers. Fumadocs builds request data from declared parameters only, + * so an operation's security requirement never reaches the generated snippet. + */ +export const generateWithAuth: CodeUsageGeneratorFn = (url, data, context) => { + const { generatorId, headers } = context.server as AuthCodeSampleContext + const generator = generators.get(generatorId) + if (!generator) { + throw new Error(`[docs] Unknown code usage generator: ${generatorId}`) + } + + const authHeaders: Record = {} + for (const [name, value] of Object.entries(headers)) { + authHeaders[name] = { value } + } + + return generator.generate(url, { ...data, header: { ...authHeaders, ...data.header } }, context) +} diff --git a/apps/docs/lib/openapi-code-samples.ts b/apps/docs/lib/openapi-code-samples.ts new file mode 100644 index 00000000000..ebfbf764bee --- /dev/null +++ b/apps/docs/lib/openapi-code-samples.ts @@ -0,0 +1,21 @@ +import type { InlineCodeUsageGenerator } from 'fumadocs-openapi/requests/generators' +import { createCodeUsageGeneratorRegistry } from 'fumadocs-openapi/requests/generators' +import { registerDefault } from 'fumadocs-openapi/requests/generators/all' +import { generateWithAuth } from '@/lib/openapi-code-samples-client' + +const generators = createCodeUsageGeneratorRegistry() +registerDefault(generators) + +/** + * Replace every built-in language sample with one that prepends `headers`, + * preserving the built-in tab order, language, and label. + */ +export function buildAuthCodeSamples(headers: Record): InlineCodeUsageGenerator[] { + return Array.from(generators.map().entries()).map(([id, generator]) => ({ + id, + lang: generator.lang, + label: generator.label, + source: generateWithAuth, + serverContext: { generatorId: id, headers }, + })) +} diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index f67d60db719..7841fd863ad 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -1,6 +1,9 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' +import type { MethodInformation } from 'fumadocs-openapi' +import type { InlineCodeUsageGenerator } from 'fumadocs-openapi/requests/generators' import { createOpenAPI } from 'fumadocs-openapi/server' +import { buildAuthCodeSamples } from '@/lib/openapi-code-samples' import { OPENAPI_SPEC_FILES } from '@/lib/openapi-specs' export const openapi = createOpenAPI({ @@ -75,6 +78,109 @@ function getSpecs(): Record[] { return cachedSpecs } +type SecurityRequirement = Record + +interface SecurityScheme { + type?: string + in?: string + name?: string + scheme?: string +} + +interface SharedSecurity { + security: SecurityRequirement[] + schemes: Record +} + +const AUTH_SAMPLE_VALUE = 'YOUR_API_KEY' + +let cachedSharedSecurity: SharedSecurity | null = null + +/** + * Document-level security shared by every rendered spec. Code samples are + * generated from an operation alone, with no handle on the document that owns + * it, so the specs must agree on their default security — a spec that diverges + * would silently get another document's auth in its samples. + */ +function getSharedSecurity(): SharedSecurity { + if (cachedSharedSecurity) return cachedSharedSecurity + + let shared: SharedSecurity | undefined + let sharedFile: string | undefined + + getSpecs().forEach((spec, index) => { + const file = OPENAPI_SPEC_FILES[index] + const current: SharedSecurity = { + security: (spec.security as SecurityRequirement[] | undefined) ?? [], + schemes: + ((spec.components as Record | undefined)?.securitySchemes as + | Record + | undefined) ?? {}, + } + + if (!shared) { + shared = current + sharedFile = file + return + } + + if (JSON.stringify(current) !== JSON.stringify(shared)) { + throw new Error( + `[docs] ${file} declares different default security than ${sharedFile}. Every OpenAPI spec must share one security scheme so generated code samples stay correct.` + ) + } + }) + + cachedSharedSecurity = shared ?? { security: [], schemes: {} } + return cachedSharedSecurity +} + +/** + * Resolve a security requirement to the request headers a sample must send. + * The first non-empty alternative wins — an empty one means the operation also + * accepts anonymous callers, which is not what a reference example should show. + */ +function resolveAuthHeaders( + security: SecurityRequirement[], + schemes: Record +): Record { + const requirement = security.find((item) => Object.keys(item).length > 0) + if (!requirement) return {} + + const headers: Record = {} + for (const name of Object.keys(requirement)) { + const scheme = schemes[name] + if (!scheme) { + throw new Error(`[docs] Operation references undefined security scheme "${name}"`) + } + if (scheme.type === 'apiKey' && scheme.in === 'header' && scheme.name) { + headers[scheme.name] = AUTH_SAMPLE_VALUE + continue + } + if (scheme.type === 'http' && scheme.scheme === 'bearer') { + headers.Authorization = `Bearer ${AUTH_SAMPLE_VALUE}` + continue + } + throw new Error( + `[docs] Security scheme "${name}" (type ${scheme.type}) cannot be rendered as a request header in code samples` + ) + } + return headers +} + +/** + * Code samples for an operation, with its authentication header included. + * Fumadocs derives sample requests from declared parameters only, so without + * this every endpoint documents an unauthenticated call that returns `401`. + */ +export function getAuthenticatedCodeSamples(method: MethodInformation): InlineCodeUsageGenerator[] { + const shared = getSharedSecurity() + const security = (method.security as SecurityRequirement[] | undefined) ?? shared.security + const headers = resolveAuthHeaders(security, shared.schemes) + if (Object.keys(headers).length === 0) return [] + return buildAuthCodeSamples(headers) +} + /** * Locate an operation by path + method across every rendered spec, returning the * operation together with the spec that owns it so `$ref`s resolve within the