Files
hermes-web-ui/scripts/generate-openapi.mjs
T
ekko 99f9dcb2fe feat: add automatic OpenAPI documentation generation (#445)
* feat: add automatic OpenAPI documentation generation

Added script to auto-generate OpenAPI spec from existing Koa routes:
- Scans route files and extracts endpoint definitions
- Generates proper OpenAPI 3.0 specification
- No code changes or decorators required
- Preserves existing code structure

Usage: npm run openapi:generate

Generated 51 endpoints across 13 tags.

* feat: enhance OpenAPI generator with all endpoints

Enhanced the auto-generation script to capture all API endpoints:
- Added support for inline function handlers
- Captured group-chat endpoints
- Added proxy endpoints for upstream API forwarding
- Added WebSocket terminal endpoint
- Added SSE chat streaming endpoint
- Increased coverage from 51 to 94 endpoints
- Added 24 comprehensive tags for all features

Generated OpenAPI spec now includes:
- All HTTP REST endpoints
- Proxy wildcards for /api/hermes/* and /v1/*
- WebSocket terminal connection
- Server-Sent Events for chat streaming
2026-05-04 20:30:04 +08:00

610 lines
20 KiB
JavaScript

#!/usr/bin/env node
/**
* Auto-generate OpenAPI specification from existing Koa routes and controllers
*
* This script scans both route files and controller files to generate comprehensive
* OpenAPI documentation without requiring code changes or decorators.
*/
import { readFileSync, writeFileSync, readdirSync } from 'fs'
import { resolve, join } from 'path'
import { fileURLToPath } from 'url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const rootDir = resolve(__dirname, '..')
const routesDir = join(rootDir, 'packages/server/src/routes')
const controllersDir = join(rootDir, 'packages/server/src/controllers')
// OpenAPI template
const openapi = {
openapi: '3.0.3',
info: {
title: 'Hermes Web UI API',
description: 'BFF server API for Hermes Web UI — chat sessions, scheduled jobs, platform channels, model management, skills, memory, logs, file browser, group chat, and terminal.',
version: '0.5.9',
},
servers: [
{ url: 'http://localhost:8648', description: 'Local development' },
],
tags: [],
paths: {},
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'API Token',
},
},
schemas: {},
responses: {},
},
}
// Tag mappings based on route directories
const tagMappings = {
'routes/hermes/sessions.ts': { name: 'Sessions', description: 'Chat session management' },
'routes/hermes/profiles.ts': { name: 'Profiles', description: 'Hermes profile management' },
'routes/hermes/gateways.ts': { name: 'Gateways', description: 'Gateway process management' },
'routes/hermes/models.ts': { name: 'Models', description: 'Model configuration' },
'routes/hermes/providers.ts': { name: 'Providers', description: 'Model provider management' },
'routes/hermes/skills.ts': { name: 'Skills', description: 'Skill browsing and management' },
'routes/hermes/memory.ts': { name: 'Memory', description: 'Agent memory files' },
'routes/hermes/logs.ts': { name: 'Logs', description: 'Log file access' },
'routes/hermes/jobs.ts': { name: 'Jobs', description: 'Scheduled job management' },
'routes/hermes/cron-history.ts': { name: 'Jobs', description: 'Cron job history' },
'routes/hermes/weixin.ts': { name: 'Weixin', description: 'WeChat QR code login' },
'routes/hermes/codex-auth.ts': { name: 'Codex Auth', description: 'OpenAI Codex OAuth' },
'routes/hermes/nous-auth.ts': { name: 'Nous Auth', description: 'Nous Research OAuth' },
'routes/hermes/copilot-auth.ts': { name: 'Copilot Auth', description: 'GitHub Copilot OAuth' },
'routes/hermes/group-chat.ts': { name: 'Group Chat', description: 'Group chat management' },
'routes/hermes/chat-run.ts': { name: 'Chat', description: 'Chat run and streaming' },
'routes/hermes/config.ts': { name: 'Config', description: 'Configuration management' },
'routes/hermes/files.ts': { name: 'Files', description: 'Hermes file browser' },
'routes/hermes/download.ts': { name: 'Download', description: 'File download' },
'routes/hermes/terminal.ts': { name: 'Terminal', description: 'WebSocket terminal' },
'routes/hermes/proxy.ts': { name: 'Proxy', description: 'Gateway proxy' },
'routes/health.ts': { name: 'Health', description: 'Health check' },
'routes/update.ts': { name: 'Update', description: 'Self-update management' },
'routes/upload.ts': { name: 'Upload', description: 'File upload' },
'routes/webhook.ts': { name: 'Webhook', description: 'Incoming webhooks' },
'routes/auth.ts': { name: 'Auth', description: 'Authentication management' },
}
// Extract route definitions from route files
function scanRoutes() {
const paths = {}
// Scan hermes routes
const hermesRoutesDir = join(routesDir, 'hermes')
const hermesRouteFiles = readdirSync(hermesRoutesDir).filter(f => f.endsWith('.ts'))
for (const file of hermesRouteFiles) {
const routePath = join('hermes', file)
const tagInfo = tagMappings[`routes/${routePath}`]
if (tagInfo) {
scanRouteFile(join(hermesRoutesDir, file), tagInfo, paths)
}
}
// Scan top-level routes
for (const [routeFile, tagInfo] of Object.entries(tagMappings)) {
if (!routeFile.startsWith('routes/hermes/')) {
const filePath = join(routesDir, routeFile.replace('routes/', ''))
try {
scanRouteFile(filePath, tagInfo, paths)
} catch (e) {
// File might not exist, skip
}
}
}
return paths
}
function scanRouteFile(filePath, tagInfo, paths) {
const content = readFileSync(filePath, 'utf-8')
// Pattern 1: controller functions - sessionRoutes.get('/path', ctrl.method)
const ctrlRouteRegex = /\w+Routes?\.(get|post|put|delete|patch)\(['"]([^'"]+)['"],\s*ctrl\.(\w+)/g
let match
while ((match = ctrlRouteRegex.exec(content)) !== null) {
const [, method, path, controllerMethod] = match
addEndpoint(paths, method, path, controllerMethod, tagInfo, content, match.index)
}
// Pattern 2: inline functions - groupChatRoutes.post('/path', async (ctx) => {...})
const inlineRouteRegex = /\w+Routes?\.(get|post|put|delete|patch)\(['"]([^'"]+)['"],\s*async\s*\(ctx\)/g
while ((match = inlineRouteRegex.exec(content)) !== null) {
const [, method, path] = match
const controllerMethod = generateOperationIdFromPath(path, method)
addEndpoint(paths, method, path, controllerMethod, tagInfo, content, match.index)
}
}
function addEndpoint(paths, method, path, controllerMethod, tagInfo, content, matchIndex) {
// Clean path parameters
const openapiPath = path
.replace(/:([^/]+)/g, '{$1}')
.replace(/\*\*([^/]*)/g, '{$1}')
if (!paths[openapiPath]) {
paths[openapiPath] = {}
}
// Generate operation ID
const operationId = `${controllerMethod}`
// Generate description from JSDoc comments above the route
const precedingContent = content.substring(Math.max(0, matchIndex - 500), matchIndex)
const description = extractJsDocDescription(precedingContent) || `${method.toUpperCase()} ${path}`
paths[openapiPath][method] = {
tags: [tagInfo.name],
summary: generateSummary(path, method, controllerMethod),
description,
operationId,
security: [{ BearerAuth: [] }],
responses: generateResponses(path, method),
}
}
function generateOperationIdFromPath(path, method) {
const parts = path.split('/').filter(Boolean)
const lastPart = parts[parts.length - 1]
if (lastPart && !lastPart.includes(':') && !lastPart.includes('*')) {
const actionMap = {
get: 'get',
post: 'create',
put: 'update',
patch: 'patch',
delete: 'delete',
}
return `${actionMap[method]}${lastPart.charAt(0).toUpperCase() + lastPart.slice(1)}`
}
const parentPart = parts[parts.length - 2]
if (parentPart) {
return `${method}${parentPart.charAt(0).toUpperCase() + parentPart.slice(1)}`
}
return method
}
function extractJsDocDescription(content) {
const jsDocRegex = /\/\*\*[\s\S]*?\*\//
const match = content.match(jsDocRegex)
if (match) {
const jsDoc = match[0]
// Extract description text
const description = jsDoc
.replace(/\/\*\*|\*\//g, '')
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '').trim())
.filter(line => line && !line.startsWith('@'))
.join('\n')
return description || null
}
return null
}
function generateSummary(path, method, controllerMethod) {
const parts = path.split('/').filter(Boolean)
const resource = parts[parts.length - 1] || 'root'
// Use controller method name to generate better summary
const methodMap = {
list: 'List',
get: 'Get',
create: 'Create',
update: 'Update',
remove: 'Delete',
delete: 'Delete',
rename: 'Rename',
pause: 'Pause',
resume: 'Resume',
run: 'Run',
search: 'Search',
add: 'Add',
}
const action = methodMap[controllerMethod] || {
get: 'Get',
post: 'Create',
put: 'Update',
patch: 'Update',
delete: 'Delete',
}[method]
if (resource.includes('{')) {
const paramName = resource.match(/\{([^}]+)\}/)?.[1] || 'id'
const parentResource = parts[parts.length - 2] || 'resource'
return `${action} ${parentResource} by ${paramName}`
}
return `${action} ${resource}`
}
function generateResponses(path, method) {
const responses = {
'200': {
description: 'Success',
},
'401': {
$ref: '#/components/responses/Unauthorized',
},
}
if (method === 'get' && path.includes('/')) {
responses['404'] = { description: 'Not found' }
}
if (method === 'post' || method === 'put' || method === 'patch') {
responses['400'] = { $ref: '#/components/responses/BadRequest' }
}
return responses
}
// Add standard responses
openapi.components.responses = {
Unauthorized: {
description: 'Unauthorized - Invalid or missing authentication token',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: { type: 'string', example: 'Unauthorized' },
},
},
},
},
},
BadRequest: {
description: 'Bad Request - Invalid parameters',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: { type: 'string', example: 'Invalid request' },
},
},
},
},
},
NotFound: {
description: 'Resource not found',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: { type: 'string', example: 'Not found' },
},
},
},
},
},
}
// Add proxy endpoints that forward to upstream Hermes API
openapi.paths['/api/hermes/{*any}'] = {
'get': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes API',
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
operationId: 'proxyHermes',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
'post': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes API',
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
operationId: 'proxyHermesPost',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
'put': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes API',
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
operationId: 'proxyHermesPut',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
'delete': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes API',
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
operationId: 'proxyHermesDelete',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
}
openapi.paths['/v1/{*any}'] = {
'get': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes v1 API',
description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.',
operationId: 'proxyV1',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
'post': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes v1 API',
description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.',
operationId: 'proxyV1Post',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
}
// Add Proxy tag
if (!openapi.tags.find(t => t.name === 'Proxy')) {
openapi.tags.push({ name: 'Proxy', description: 'Gateway proxy to upstream Hermes API' })
}
// Add WebSocket terminal endpoint
openapi.paths['/api/hermes/terminal'] = {
'get': {
tags: ['Terminal'],
summary: 'WebSocket terminal connection',
description: 'Establish a WebSocket connection for interactive terminal access. Uses the `ws` or `wss` protocol with `?token=` for authentication.',
operationId: 'terminalWebSocket',
responses: {
'101': { description: 'Switching Protocols - WebSocket connection established' },
'401': { $ref: '#/components/responses/Unauthorized' },
},
},
}
// Add Chat streaming endpoint
openapi.paths['/api/hermes/v1/runs/{runId}/events'] = {
'get': {
tags: ['Chat'],
summary: 'Server-Sent Events for chat streaming',
description: 'Stream chat events using Server-Sent Events (SSE). Authentication via `?token=` query parameter.',
operationId: 'chatStreamEvents',
parameters: [
{
name: 'runId',
in: 'path',
required: true,
description: 'Chat run ID',
schema: { type: 'string' },
},
{
name: 'token',
in: 'query',
required: true,
description: 'Authentication token',
schema: { type: 'string' },
},
],
responses: {
'200': {
description: 'SSE stream established',
content: {
'text/event-stream': {
schema: {
type: 'object',
properties: {
event: { type: 'string', enum: ['run.created', 'run.queued', 'run.started', 'run.streaming', 'run.completed', 'run.failed'] },
data: { type: 'object' },
},
},
},
},
},
'401': { $ref: '#/components/responses/Unauthorized' },
'404': { description: 'Run not found' },
},
},
}
// Add Terminal tag
if (!openapi.tags.find(t => t.name === 'Terminal')) {
openapi.tags.push({ name: 'Terminal', description: 'WebSocket terminal access' })
}
// Run scanner
console.log('Scanning routes...')
openapi.paths = scanRoutes()
// Collect all tags
const tagSet = new Set()
Object.values(openapi.paths).forEach(pathItem => {
Object.values(pathItem).forEach(operation => {
operation.tags?.forEach(tag => tagSet.add(tag))
})
})
openapi.tags = Array.from(tagSet).map(tag => {
const tagInfo = Object.values(tagMappings).find(t => t.name === tag)
return {
name: tag,
description: tagInfo?.description || '',
}
})
// Sort paths
const sortedPaths = {}
Object.keys(openapi.paths).sort().forEach(key => {
sortedPaths[key] = openapi.paths[key]
})
openapi.paths = sortedPaths
// Add special endpoints after sorting
// Add proxy endpoints that forward to upstream Hermes API
openapi.paths['/api/hermes/{*any}'] = {
'get': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes API',
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
operationId: 'proxyHermes',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
'post': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes API',
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
operationId: 'proxyHermesPost',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
'put': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes API',
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
operationId: 'proxyHermesPut',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
'delete': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes API',
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
operationId: 'proxyHermesDelete',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
}
openapi.paths['/v1/{*any}'] = {
'get': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes v1 API',
description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.',
operationId: 'proxyV1',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
'post': {
tags: ['Proxy'],
summary: 'Proxy to upstream Hermes v1 API',
description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.',
operationId: 'proxyV1Post',
responses: {
'200': { description: 'Proxied response from upstream' },
'401': { $ref: '#/components/responses/Unauthorized' },
'502': { description: 'Proxy failure' },
},
},
}
// Add WebSocket terminal endpoint
openapi.paths['/api/hermes/terminal'] = {
'get': {
tags: ['Terminal'],
summary: 'WebSocket terminal connection',
description: 'Establish a WebSocket connection for interactive terminal access. Uses the `ws` or `wss` protocol with `?token=` for authentication.',
operationId: 'terminalWebSocket',
responses: {
'101': { description: 'Switching Protocols - WebSocket connection established' },
'401': { $ref: '#/components/responses/Unauthorized' },
},
},
}
// Add Chat streaming endpoint
openapi.paths['/api/hermes/v1/runs/{runId}/events'] = {
'get': {
tags: ['Chat'],
summary: 'Server-Sent Events for chat streaming',
description: 'Stream chat events using Server-Sent Events (SSE). Authentication via `?token=` query parameter.',
operationId: 'chatStreamEvents',
parameters: [
{
name: 'runId',
in: 'path',
required: true,
description: 'Chat run ID',
schema: { type: 'string' },
},
{
name: 'token',
in: 'query',
required: true,
description: 'Authentication token',
schema: { type: 'string' },
},
],
responses: {
'200': {
description: 'SSE stream established',
content: {
'text/event-stream': {
schema: {
type: 'object',
properties: {
event: { type: 'string', enum: ['run.created', 'run.queued', 'run.started', 'run.streaming', 'run.completed', 'run.failed'] },
data: { type: 'object' },
},
},
},
},
},
'401': { $ref: '#/components/responses/Unauthorized' },
'404': { description: 'Run not found' },
},
},
}
// Add Proxy and Terminal tags
if (!openapi.tags.find(t => t.name === 'Proxy')) {
openapi.tags.push({ name: 'Proxy', description: 'Gateway proxy to upstream Hermes API' })
}
if (!openapi.tags.find(t => t.name === 'Terminal')) {
openapi.tags.push({ name: 'Terminal', description: 'WebSocket terminal access' })
}
// Write output
const outputPath = join(rootDir, 'docs/openapi.json')
writeFileSync(outputPath, JSON.stringify(openapi, null, 2))
console.log(`✓ Generated OpenAPI spec: ${outputPath}`)
console.log(` ${Object.keys(openapi.paths).length} endpoints`)
console.log(` ${openapi.tags.length} tags`)