'use client'; import consola from 'consola'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ApiEndpoint, LoadedSchemaEntry, OpenApiInfo, OpenApiSchema, SchemaSource, UseOpenApiSchemaReturn } from '../types'; import { sampleSchemaJson } from '../utils/sampler'; import { dereferenceSchema } from '../utils/schemaExport'; import { joinUrl, resolveBaseUrl } from '../utils/url'; type JsonSchemaNode = Record; // HTTP methods to extract from OpenAPI schema. Covers the full set of // OpenAPI Operation Object keys — ``head`` / ``options`` / ``trace`` are // rarer but valid, and silently dropping them hid real endpoints. const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const; // Extract endpoints from OpenAPI schema (all methods). ``baseUrl`` is // resolved by the caller via ``resolveBaseUrl`` — we just paste it onto // the front of each path here. ``schemaId`` is tagged onto every // endpoint so downstream consumers (sections-mode sidebar, anchors, URL // sync) can correlate endpoints back to their source schema. // // ``specRoot`` is the raw, un-dereferenced schema — passed to // ``openapi-sampler`` so it can resolve any ``$ref`` nodes our own // ``dereferenceSchema`` left behind (depth-limited, external, or // circular). Without it the sampler throws on deep ref chains. const extractEndpoints = ( schema: OpenApiSchema, baseUrl: string, schemaId?: string, specRoot?: OpenApiSchema, ): ApiEndpoint[] => { const endpoints: ApiEndpoint[] = []; if (!schema.paths) return []; for (const [path, methods] of Object.entries(schema.paths)) { for (const method of HTTP_METHODS) { const op = (methods as any)[method]; if (!op) continue; const methodUpper = method.toUpperCase(); const summary = (op.summary || '').trim(); const description = op.description || summary || `${methodUpper} ${path}`; const category = op.tags?.[0] || 'Other'; const parameters: Array<{ name: string; type: string; required: boolean; description?: string; }> = []; // Collect parameters (path-level + operation-level) const allParams = [...((methods as any).parameters || []), ...(op.parameters || [])]; for (const param of allParams) { parameters.push({ name: param.name, type: param.schema?.type || 'string', required: param.required || false, description: param.description, }); } // Collect responses. We also extract the ``application/json`` // schema (preferring it, falling back to whatever media type is // present) and generate a sampled example — the docs layout // renders it as a collapsible "Example response" block under the // status-code table. ``writeOnly`` fields are skipped so secrets // declared ``writeOnly: true`` don't leak into response samples. const responses: NonNullable = []; if (op.responses) { for (const [code, response] of Object.entries(op.responses)) { const respContent = (response as any).content as Record | undefined; const contentKeys = respContent ? Object.keys(respContent) : []; const chosenContentType = respContent?.['application/json'] ? 'application/json' : contentKeys[0]; const chosen = chosenContentType ? respContent?.[chosenContentType] : undefined; const respSchema = chosen?.schema as JsonSchemaNode | undefined; // Hand-written example wins over a synthesised one. const explicit = stringifyExample(explicitMediaExample(chosen)); responses.push({ code, description: (response as any).description || `Response ${code}`, contentType: chosenContentType, schema: respSchema, example: explicit ?? (respSchema ? sampleSchemaJson(respSchema, { skipWriteOnly: true }, specRoot) : undefined), }); } } // Extract request body info — keep the dereferenced schema so // downstream UI can render a fields table and generate a starter // example instead of showing an opaque ``object`` / ``array`` tag. // ``readOnly`` fields are skipped: the body is what the client // *sends*, so server-owned fields (id, created_at, …) must not // pre-fill the editor. let requestBody: ApiEndpoint['requestBody']; if (op.requestBody) { const content = op.requestBody.content; const mediaType = content?.['application/json'] || content?.[Object.keys(content || {})[0]]; const rawSchema = mediaType?.schema as JsonSchemaNode | undefined; // Hand-written example wins over a synthesised one. const explicit = stringifyExample(explicitMediaExample(mediaType)); requestBody = { type: (rawSchema?.type as string | undefined) || 'object', description: op.requestBody.description, schema: rawSchema, example: explicit ?? (rawSchema ? sampleSchemaJson(rawSchema, { skipReadOnly: true }, specRoot) : undefined), }; } const endpoint: ApiEndpoint = { name: path.split('/').pop() || path, method: methodUpper, path: baseUrl ? joinUrl(baseUrl, path) : path, summary, description, category, parameters: parameters.length > 0 ? parameters : undefined, requestBody, responses: responses.length > 0 ? responses : undefined, schemaId, }; endpoints.push(endpoint); } } return endpoints; }; // Pull a hand-written example off a media-type object, if present. // OpenAPI lets authors attach a canonical example two ways: // - ``example`` — a single inline value (3.0 + 3.1). // - ``examples`` — a named map of Example Objects (3.0 + 3.1); we use // the first entry's ``value``. // A hand-written example is more trustworthy than a synthesised one, so // callers prefer this over ``openapi-sampler`` output. const explicitMediaExample = (media: Record | undefined): unknown => { if (!media) return undefined; if (media.example !== undefined) return media.example; const examples = media.examples as Record | undefined; if (examples && typeof examples === 'object') { for (const ex of Object.values(examples)) { if (ex && typeof ex === 'object' && 'value' in ex) return ex.value; } } return undefined; }; const stringifyExample = (value: unknown): string | undefined => { if (value === undefined) return undefined; try { return JSON.stringify(value, null, 2); } catch { return undefined; } }; // Get unique categories from endpoints const getCategories = (endpoints: ApiEndpoint[]): string[] => { const categories = new Set(); endpoints.forEach((endpoint) => categories.add(endpoint.category)); return Array.from(categories).sort(); }; // Fetch schema from URL. Parses JSON explicitly (rather than // ``response.json()``) so a server that returns HTML or YAML yields a // readable error instead of an opaque ``SyntaxError: Unexpected token``. const fetchSchema = async (url: string): Promise => { const response = await fetch(url, { headers: { 'Accept': 'application/json', }, }); if (!response.ok) { throw new Error(`Failed to fetch schema (HTTP ${response.status} ${response.statusText})`); } const text = await response.text(); try { return JSON.parse(text) as OpenApiSchema; } catch { const looksYaml = /\.ya?ml($|\?)/i.test(url) || /^\s*openapi\s*:/m.test(text); throw new Error( looksYaml ? 'Schema appears to be YAML — only JSON OpenAPI documents are supported.' : 'Schema response is not valid JSON.', ); } }; interface UseOpenApiSchemaProps { schemas: SchemaSource[]; defaultSchemaId?: string; /** Global base URL override from ``PlaygroundConfig.baseUrl``. * Per-schema ``SchemaSource.baseUrl`` takes precedence over this. */ baseUrl?: string; /** When ``true`` the hook fetches every schema in ``schemas`` (not just * the active one) and exposes them via ``schemasData``. Used by the * ``sections`` grouping mode — the docs column concatenates endpoints * from every schema, so they all need to be on the client. Default * is ``false`` to preserve the original lazy behaviour. */ preloadAll?: boolean; } interface SchemaLoadState { loading: boolean; error: string | null; } export default function useOpenApiSchema({ schemas, defaultSchemaId, baseUrl: configBaseUrl, preloadAll = false, }: UseOpenApiSchemaProps): UseOpenApiSchemaReturn { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [currentSchemaId, setCurrentSchemaId] = useState( defaultSchemaId || schemas[0]?.id ); const [loadedSchemas, setLoadedSchemas] = useState>( new Map() ); // Per-schema loading/error state for ``preloadAll`` — each schema may // succeed or fail independently, and the UI wants to render partial // results while slow/broken ones are still resolving. const [loadStates, setLoadStates] = useState>(new Map()); const currentSchema = useMemo( () => schemas.find((s) => s.id === currentSchemaId) || null, [schemas, currentSchemaId] ); const currentOpenApiSchema = useMemo( () => (currentSchemaId ? loadedSchemas.get(currentSchemaId) : null), [loadedSchemas, currentSchemaId] ); // Dereference once per schema load so endpoint extraction sees fully // resolved ``$ref`` graphs. The raw schema is still exposed via // ``rawSchema`` for Copy-for-AI (juniors may want to hand the raw // document to an LLM and have it resolve refs itself). const dereferencedSchema = useMemo( () => (currentOpenApiSchema ? dereferenceSchema(currentOpenApiSchema) : null), [currentOpenApiSchema], ); // Resolve base URL with priority chain. Centralised in ``resolveBaseUrl`` // so the same logic is reused by the schema-export utilities. const resolvedBaseUrl = useMemo( () => resolveBaseUrl({ schemaSource: currentSchema?.baseUrl, config: configBaseUrl, fromServers: currentOpenApiSchema?.servers?.[0]?.url, }), [currentSchema?.baseUrl, configBaseUrl, currentOpenApiSchema], ); const endpoints = useMemo( () => dereferencedSchema ? extractEndpoints(dereferencedSchema, resolvedBaseUrl, currentSchemaId, currentOpenApiSchema ?? undefined) : [], [dereferencedSchema, resolvedBaseUrl, currentSchemaId, currentOpenApiSchema] ); const categories = useMemo(() => getCategories(endpoints), [endpoints]); const schemaInfo = useMemo(() => { if (!currentOpenApiSchema?.info) return null; const { title, version, description } = currentOpenApiSchema.info; return { title, version, description, servers: currentOpenApiSchema.servers, }; }, [currentOpenApiSchema]); // Load schema when current schema changes (single-schema mode) useEffect(() => { if (preloadAll) return; if (!currentSchema) return; // Skip if already loaded if (loadedSchemas.has(currentSchema.id)) { setLoading(false); return; } setLoading(true); setError(null); if (currentSchema.data) { setLoadedSchemas((prev) => new Map(prev).set(currentSchema.id, currentSchema.data!)); consola.success(`Schema loaded (static): ${currentSchema.name}`); setLoading(false); return; } if (!currentSchema.url) { setError('SchemaSource requires either url or data'); setLoading(false); return; } // Guard against a stale fetch resolving after the user switched // schemas — without it a slow earlier request could flip ``loading`` // off (or surface an error) for a schema that is no longer current. let cancelled = false; fetchSchema(currentSchema.url) .then((schema) => { if (cancelled) return; setLoadedSchemas((prev) => new Map(prev).set(currentSchema.id, schema)); consola.success(`Schema loaded: ${currentSchema.name}`); setLoading(false); }) .catch((err) => { if (cancelled) return; consola.error(`Error loading schema from ${currentSchema.url}:`, err); setError(err instanceof Error ? err.message : 'Failed to load schema'); setLoading(false); }); return () => { cancelled = true; }; }, [currentSchema, loadedSchemas, preloadAll]); // Preload every schema (sections-grouping mode). Each schema is fetched // independently — a slow or broken source doesn't block the rest. useEffect(() => { if (!preloadAll) return; if (schemas.length === 0) { setLoading(false); return; } let cancelled = false; const pending = schemas.filter((s) => !loadedSchemas.has(s.id)); if (pending.length === 0) { setLoading(false); return; } setLoading(true); setLoadStates((prev) => { const next = new Map(prev); for (const s of pending) next.set(s.id, { loading: true, error: null }); return next; }); Promise.allSettled( pending.map((s) => s.data ? Promise.resolve({ id: s.id, name: s.name, schema: s.data }) : s.url ? fetchSchema(s.url).then((schema) => ({ id: s.id, name: s.name, schema })) : Promise.reject(new Error('SchemaSource requires either url or data')), ), ).then((results) => { if (cancelled) return; setLoadedSchemas((prev) => { const next = new Map(prev); for (const r of results) { if (r.status === 'fulfilled') { next.set(r.value.id, r.value.schema); consola.success(`Schema loaded: ${r.value.name}`); } } return next; }); setLoadStates((prev) => { const next = new Map(prev); results.forEach((r, i) => { const src = pending[i]!; if (r.status === 'fulfilled') { next.set(src.id, { loading: false, error: null }); } else { const msg = r.reason instanceof Error ? r.reason.message : 'Failed to load schema'; consola.error(`Error loading schema from ${src.url}:`, r.reason); next.set(src.id, { loading: false, error: msg }); } }); return next; }); setLoading(false); }); return () => { cancelled = true; }; }, [preloadAll, schemas, loadedSchemas]); const schemasData = useMemo(() => { if (!preloadAll) return []; return schemas.map((src) => { const raw = loadedSchemas.get(src.id) ?? null; const deref = raw ? dereferenceSchema(raw) : null; const resolved = resolveBaseUrl({ schemaSource: src.baseUrl, config: configBaseUrl, fromServers: raw?.servers?.[0]?.url, }); const info: OpenApiInfo | null = raw?.info ? { title: raw.info.title, version: raw.info.version, description: raw.info.description, servers: raw.servers, } : null; const eps = deref ? extractEndpoints(deref, resolved, src.id, raw ?? undefined) : []; const state = loadStates.get(src.id) ?? { loading: !raw, error: null }; return { source: src, info, rawSchema: raw, endpoints: eps, resolvedBaseUrl: resolved || undefined, loading: state.loading, error: state.error, }; }); }, [preloadAll, schemas, loadedSchemas, loadStates, configBaseUrl]); const setCurrentSchema = useCallback((schemaId: string) => { setCurrentSchemaId(schemaId); }, []); const refresh = useCallback(() => { if (!currentSchema) return; if (currentSchema.data) { // Static data — nothing to re-fetch, just re-trigger a render cycle. setLoadedSchemas((prev) => new Map(prev).set(currentSchema.id, currentSchema.data!)); return; } if (!currentSchema.url) return; setLoading(true); setError(null); // Remove from cache to force reload setLoadedSchemas((prev) => { const next = new Map(prev); next.delete(currentSchema.id); return next; }); fetchSchema(currentSchema.url) .then((schema) => { setLoadedSchemas((prev) => new Map(prev).set(currentSchema.id, schema)); consola.success(`Schema refreshed: ${currentSchema.name}`); setLoading(false); }) .catch((err) => { consola.error(`Error refreshing schema from ${currentSchema.url}:`, err); setError(err instanceof Error ? err.message : 'Failed to refresh schema'); setLoading(false); }); }, [currentSchema]); return { loading, error, endpoints, categories, schemas, currentSchema, schemaInfo, rawSchema: currentOpenApiSchema ?? null, // Consumers expect ``undefined`` when no base URL was resolved (for // conditional ``{ baseUrl?: … }`` plumbing). Turn the empty-string // convention from the resolver into undefined at the API boundary. resolvedBaseUrl: resolvedBaseUrl || undefined, setCurrentSchema, refresh, schemasData, }; }