Skip to content
Open
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
149 changes: 118 additions & 31 deletions apps/inspector/src/components/api-routes-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Code,
Settings2,
ListFilter,
FileDown,
} from 'lucide-react'

import { api, type RouteInfo } from '@/api/client'
Expand All @@ -28,6 +29,7 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
Expand All @@ -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',
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -392,42 +451,70 @@ export function ApiRoutesTable() {
searchPlaceholder="Search endpoints..."
defaultSorting={[{ id: 'path', desc: false }]}
filters={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<ListFilter className="h-4 w-4" />
<span className="text-xs font-medium">
{methodFilterLabel}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuCheckboxItem
checked={methodFilter.length === 0}
onCheckedChange={(checked) =>
checked && clearMethodFilter()
}
>
All methods
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{availableMethods.map((method) => (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<ListFilter className="h-4 w-4" />
<span className="text-xs font-medium">
{methodFilterLabel}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuCheckboxItem
key={method}
checked={methodFilter.includes(method)}
checked={methodFilter.length === 0}
onCheckedChange={(checked) =>
handleToggleMethod(method, !!checked)
checked && clearMethodFilter()
}
>
{method}
All methods
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuSeparator />
{availableMethods.map((method) => (
<DropdownMenuCheckboxItem
key={method}
checked={methodFilter.includes(method)}
onCheckedChange={(checked) =>
handleToggleMethod(method, !!checked)
}
>
{method}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>

<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
disabled={!routes || routes.length === 0}
>
<FileDown className="h-4 w-4" />
<span className="text-xs font-medium">Export Docs</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={exportOpenApi}>
OpenAPI v3 JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={exportPostman}>
Postman Collection
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={exportScalar}>
Scalar Docs (HTML)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
/>
</div>
Expand Down
200 changes: 200 additions & 0 deletions apps/inspector/src/lib/docs-export.ts
Original file line number Diff line number Diff line change
@@ -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<OpenApiHttpMethod>([
'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<string, Partial<Record<OpenApiHttpMethod, OpenApiOperation>>>
}

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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Next Lens API Docs</title>
</head>
<body>
<script id="api-reference" type="application/json">${serializedSpec}</script>
<script src="${SCALAR_CDN_URL}"></script>
<script>
Scalar.createApiReference('#api-reference', {
theme: 'default',
})
</script>
</body>
</html>`
}

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)
}