Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/docs/app/[lang]/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -71,6 +71,7 @@ function stripLocalePrefix(url: string, lang: string): string {

const APIPage = createAPIPage(openapi, {
playground: { enabled: false },
generateCodeSamples: getAuthenticatedCodeSamples,
client: {
operation: { APIExampleSelector },
},
Expand Down
37 changes: 37 additions & 0 deletions apps/docs/lib/openapi-code-samples-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

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<string, { value: string }> = {}
for (const [name, value] of Object.entries(headers)) {
authHeaders[name] = { value }
}

return generator.generate(url, { ...data, header: { ...authHeaders, ...data.header } }, context)
}
21 changes: 21 additions & 0 deletions apps/docs/lib/openapi-code-samples.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): InlineCodeUsageGenerator[] {
return Array.from(generators.map().entries()).map(([id, generator]) => ({
id,
lang: generator.lang,
label: generator.label,
source: generateWithAuth,
serverContext: { generatorId: id, headers },
}))
}
106 changes: 106 additions & 0 deletions apps/docs/lib/openapi.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -75,6 +78,109 @@ function getSpecs(): Record<string, unknown>[] {
return cachedSpecs
}

type SecurityRequirement = Record<string, string[]>

interface SecurityScheme {
type?: string
in?: string
name?: string
scheme?: string
}

interface SharedSecurity {
security: SecurityRequirement[]
schemes: Record<string, SecurityScheme>
}

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<string, unknown> | undefined)?.securitySchemes as
| Record<string, SecurityScheme>
| 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<string, SecurityScheme>
): Record<string, string> {
const requirement = security.find((item) => Object.keys(item).length > 0)
if (!requirement) return {}

const headers: Record<string, string> = {}
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 []
Comment thread
TheodoreSpeaks marked this conversation as resolved.
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
Expand Down
Loading