From b86615bb1fbb7a7fe220af78bb4e70f76118ee0e Mon Sep 17 00:00:00 2001 From: Abbas Date: Fri, 20 Mar 2026 11:13:10 +0800 Subject: [PATCH] feat(inspector): export API docs formats Add an Export Docs control in the API Routes view so users can download OpenAPI v3, Postman collections, and Scalar HTML docs directly from discovered routes. This makes sharing and consuming route documentation faster without extra setup. --- .../src/components/api-routes-table.tsx | 149 ++++++++++--- apps/inspector/src/lib/docs-export.ts | 200 ++++++++++++++++++ 2 files changed, 318 insertions(+), 31 deletions(-) create mode 100644 apps/inspector/src/lib/docs-export.ts diff --git a/apps/inspector/src/components/api-routes-table.tsx b/apps/inspector/src/components/api-routes-table.tsx index fc093fe..43a607a 100644 --- a/apps/inspector/src/components/api-routes-table.tsx +++ b/apps/inspector/src/components/api-routes-table.tsx @@ -8,6 +8,7 @@ import { Code, Settings2, ListFilter, + FileDown, } from 'lucide-react' import { api, type RouteInfo } from '@/api/client' @@ -28,6 +29,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' @@ -39,6 +41,12 @@ import { import { cn, formatPath } from '@/lib/utils' import { toast } from 'sonner' import { useInspector } from '@/context/inspector-context' +import { + buildOpenApiDocument, + buildPostmanCollection, + buildScalarHtml, + downloadTextFile, +} from '@/lib/docs-export' const METHOD_ORDER = [ 'GET', @@ -188,6 +196,57 @@ export function ApiRoutesTable() { const clearMethodFilter = () => setMethodFilter([]) + const exportOpenApi = () => { + if (!routes || routes.length === 0) return + + try { + const openApi = buildOpenApiDocument(routes) + downloadTextFile( + 'openapi.json', + JSON.stringify(openApi, null, 2), + 'application/json', + ) + toast.success('OpenAPI v3 exported') + } catch (err) { + toast.error('Failed to export OpenAPI v3', { + description: (err as Error).message, + }) + } + } + + const exportPostman = () => { + if (!routes || routes.length === 0) return + + try { + const collection = buildPostmanCollection(routes) + downloadTextFile( + 'postman-collection.json', + JSON.stringify(collection, null, 2), + 'application/json', + ) + toast.success('Postman collection exported') + } catch (err) { + toast.error('Failed to export Postman collection', { + description: (err as Error).message, + }) + } + } + + const exportScalar = () => { + if (!routes || routes.length === 0) return + + try { + const openApi = buildOpenApiDocument(routes) + const scalarHtml = buildScalarHtml(openApi) + downloadTextFile('scalar-docs.html', scalarHtml, 'text/html') + toast.success('Scalar docs exported') + } catch (err) { + toast.error('Failed to export Scalar docs', { + description: (err as Error).message, + }) + } + } + const methodFilterLabel = methodFilter.length === 0 ? 'All methods' @@ -392,42 +451,70 @@ export function ApiRoutesTable() { searchPlaceholder="Search endpoints..." defaultSorting={[{ id: 'path', desc: false }]} filters={ - - - - - - - checked && clearMethodFilter() - } - > - All methods - - - {availableMethods.map((method) => ( +
+ + + + + - handleToggleMethod(method, !!checked) + checked && clearMethodFilter() } > - {method} + All methods - ))} - - + + {availableMethods.map((method) => ( + + handleToggleMethod(method, !!checked) + } + > + {method} + + ))} + + + + + + + + + + OpenAPI v3 JSON + + + Postman Collection + + + + Scalar Docs (HTML) + + + +
} /> diff --git a/apps/inspector/src/lib/docs-export.ts b/apps/inspector/src/lib/docs-export.ts new file mode 100644 index 0000000..96be0d7 --- /dev/null +++ b/apps/inspector/src/lib/docs-export.ts @@ -0,0 +1,200 @@ +import type { RouteInfo } from '@/api/client' + +const DEFAULT_SERVER_URL = 'http://localhost:3000' +const SCALAR_CDN_URL = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' +const OPENAPI_METHODS = new Set([ + 'get', + 'post', + 'put', + 'patch', + 'delete', +]) + +type OpenApiHttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' + +type OpenApiOperation = { + operationId: string + summary: string + parameters?: Array<{ + name: string + in: 'path' + required: true + schema: { type: 'string' } + }> + responses: { + '200': { + description: string + } + } +} + +type OpenApiDocument = { + openapi: '3.0.3' + info: { + title: string + version: string + description: string + } + servers: Array<{ url: string }> + paths: Record>> +} + +function toOpenApiPath(path: string): string { + return path.replace(/:([A-Za-z0-9_]+)(?:\*\??)?/g, '{$1}') +} + +function toPathParams(path: string): string[] { + const matches = path.matchAll(/:([A-Za-z0-9_]+)(?:\*\??)?/g) + return Array.from(new Set(Array.from(matches, (match) => match[1]))) +} + +function toOperationId(method: string, path: string): string { + const methodPart = method.toLowerCase() + const segments = path + .replace(/^\/+/, '') + .split('/') + .filter(Boolean) + .map((segment) => segment.replace(/[^A-Za-z0-9]/g, ' ')) + .join(' ') + .trim() + + const pathPart = segments + .split(/\s+/) + .filter(Boolean) + .map((token, index) => + index === 0 + ? token.toLowerCase() + : token[0].toUpperCase() + token.slice(1).toLowerCase(), + ) + .join('') + + return pathPart + ? `${methodPart}${pathPart[0].toUpperCase()}${pathPart.slice(1)}` + : methodPart +} + +export function buildOpenApiDocument(routes: RouteInfo[]): OpenApiDocument { + const paths: OpenApiDocument['paths'] = {} + + for (const route of routes) { + const openApiPath = toOpenApiPath(route.path) + const pathParams = toPathParams(route.path) + + if (!paths[openApiPath]) { + paths[openApiPath] = {} + } + + for (const method of route.methods) { + const normalizedMethod = method.toLowerCase() as OpenApiHttpMethod + if (!OPENAPI_METHODS.has(normalizedMethod)) { + continue + } + + paths[openApiPath][normalizedMethod] = { + operationId: toOperationId(method, route.path), + summary: `${method} ${route.path}`, + parameters: + pathParams.length > 0 + ? pathParams.map((name) => ({ + name, + in: 'path' as const, + required: true as const, + schema: { type: 'string' as const }, + })) + : undefined, + responses: { + '200': { + description: 'Successful response', + }, + }, + } + } + } + + return { + openapi: '3.0.3', + info: { + title: 'Next Lens API', + version: '1.0.0', + description: 'Generated from Next Lens route inspector.', + }, + servers: [{ url: DEFAULT_SERVER_URL }], + paths, + } +} + +export function buildPostmanCollection(routes: RouteInfo[]) { + return { + info: { + name: 'Next Lens API', + schema: + 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', + description: 'Generated from Next Lens route inspector.', + }, + variable: [ + { + key: 'baseUrl', + value: DEFAULT_SERVER_URL, + }, + ], + item: routes.flatMap((route) => + route.methods + .filter((method) => method.toUpperCase() !== 'OPTIONS') + .map((method) => ({ + name: `${method} ${route.path}`, + request: { + method, + header: [], + url: { + raw: `{{baseUrl}}${route.path}`, + host: ['{{baseUrl}}'], + path: route.path.replace(/^\/+/, '').split('/').filter(Boolean), + }, + description: `Generated from ${route.file}`, + }, + response: [], + })), + ), + } +} + +export function buildScalarHtml(openApiDocument: OpenApiDocument): string { + const serializedSpec = JSON.stringify(openApiDocument).replace( + /<\/script>/gi, + '<\\/script>', + ) + + return ` + + + + + Next Lens API Docs + + + + + + +` +} + +export function downloadTextFile( + fileName: string, + content: string, + mimeType: string, +): void { + const blob = new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +}