diff --git a/README.md b/README.md index d684d08..859a4a7 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ function detectCommandBase(argv0?: string, argv1?: string): string function listRoutesWithExamples(app: any, cmdBase?: string): { routes: string[]; examples: string[] } function listCommandExamples(app: any, cmdBase?: string): string[] type OpenApiParam = { name: string; in: string; required?: boolean; description?: string; schema?: any } -function listRoutesWithExamplesFromOpenApi(openapi: any, cmdBase?: string): { routes: string[]; examples: string[]; params: OpenApiParam[][] } +function listRoutesWithExamplesFromOpenApi(openapi: any, cmdBase?: string, commentFiles?: string[]): { routes: string[]; examples: string[]; params: OpenApiParam[][] } +function listRoutesWithExamplesFromComments(files: string[], cmdBase?: string): { routes: string[]; examples: string[]; params: OpenApiParam[][] } type RunCliResult = { code: number; lines: string[]; req?: Request; res?: Response } function runCliDefault(app: any, argvRaw?: string[], options?: AdapterOptions): Promise // Convenience with side effects (stdout + process.exit when available) @@ -163,6 +164,33 @@ cmd user --email --age --age (integer, required) : user age ``` +If you don't maintain an OpenAPI spec, annotate your route files with comments and use the same helpers: + +```ts +// routes/user.ts +/** + * @cliDesc create a user + * @cliParam id path user id + * @cliParam email query user email required + * @cliParam age body user age + */ +app.post('/user/:id', handler) + +// elsewhere +import { listRoutesWithExamplesFromComments } from 'hono-cli-adapter' +const { examples, params } = listRoutesWithExamplesFromComments(['routes/user.ts'], 'cmd') +``` + +Each `@cliParam` line follows: + +``` +@cliParam [required] +``` + +`` is `path`, `query`, or `body`. Append `required` to mark the flag as required. Route descriptions can be provided via `@cliDesc`. + +`listRoutesWithExamplesFromOpenApi` accepts a third argument of file paths; when provided, comment annotations are used as a fallback or to supplement routes missing from the OpenAPI spec. + ### Hooks and command detection Tweak the outgoing `Request` before sending: diff --git a/src/index.ts b/src/index.ts index 717b7f4..4a599f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import minimist from 'minimist' import path from 'node:path' +import fs from 'node:fs' +import { createRequire } from 'node:module' type AnyObj = Record @@ -233,9 +235,90 @@ export type OpenApiParam = { schema?: any } +export function listRoutesWithExamplesFromComments( + files: string[], + cmdBase?: string +): { routes: string[]; examples: string[]; params: OpenApiParam[][] } { + const base = cmdBase ?? detectCommandBase() + const require = createRequire(import.meta.url) + const ts: typeof import('typescript') = require('typescript') + const routes: string[] = [] + const examples: string[] = [] + const params: OpenApiParam[][] = [] + + for (const file of files) { + let code: string + try { + code = fs.readFileSync(file, 'utf8') + } catch { + continue + } + const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true) + const text = source.getFullText() + + const visit = (node: any) => { + if (ts.isCallExpression(node)) { + const expr = node.expression + if ( + ts.isPropertyAccessExpression(expr) && + expr.name.getText(source) === 'post' + ) { + const routeArg = node.arguments[0] + if (routeArg && ts.isStringLiteral(routeArg)) { + const route = routeArg.text + + const commentRanges = + ts.getLeadingCommentRanges(text, node.pos) || [] + const paramList: OpenApiParam[] = [] + for (const range of commentRanges) { + const comment = text.slice(range.pos, range.end) + const lines = comment.split(/\r?\n/) + for (const line of lines) { + const m = line.match(/@cliParam\s+([\w-]+)\s+([\w-]+)\s+(.*)/) + if (m) { + let desc = m[3].trim() + let required = false + if (/\srequired$/.test(desc)) { + required = true + desc = desc.replace(/\srequired$/, '').trim() + } + const loc = m[2] + paramList.push({ + name: m[1], + in: loc, + required: required || loc === 'path', + description: desc + }) + } + } + } + + routes.push(route) + params.push(paramList) + + const segs = routePathToCommandSegments(route) + let example = base + (segs.length ? ' ' + segs.join(' ') : '') + for (const p of paramList) { + if (p.in === 'query' || p.in === 'body') { + example += ` --${p.name} <${p.name}>` + } + } + examples.push(example) + } + } + } + ts.forEachChild(node, visit) + } + ts.forEachChild(source, visit) + } + + return { routes, examples, params } +} + export function listRoutesWithExamplesFromOpenApi( openapi: any, - cmdBase?: string + cmdBase?: string, + commentFiles?: string[] ): { routes: string[]; examples: string[]; params: OpenApiParam[][] } { const paths = openapi?.paths || {} const base = cmdBase ?? detectCommandBase() @@ -291,6 +374,18 @@ export function listRoutesWithExamplesFromOpenApi( examples.push(example) } + if (commentFiles && commentFiles.length) { + const fromComments = listRoutesWithExamplesFromComments(commentFiles, base) + for (let i = 0; i < fromComments.routes.length; i++) { + const r = fromComments.routes[i] + if (!routes.includes(r)) { + routes.push(r) + examples.push(fromComments.examples[i]) + params.push(fromComments.params[i]) + } + } + } + return { routes, examples, params } } diff --git a/test/fixtures/commentExtra.ts b/test/fixtures/commentExtra.ts new file mode 100644 index 0000000..d04b7e7 --- /dev/null +++ b/test/fixtures/commentExtra.ts @@ -0,0 +1,5 @@ +/** + * @cliParam slug path slug id + * @cliParam token query access token required + */ +app.post('/extra/:slug', () => {}) diff --git a/test/fixtures/commentUser.ts b/test/fixtures/commentUser.ts new file mode 100644 index 0000000..8941506 --- /dev/null +++ b/test/fixtures/commentUser.ts @@ -0,0 +1,6 @@ +/** + * @cliParam id path user id + * @cliParam email query user email required + * @cliParam age body user age + */ +app.post('/user/:id', () => {}) diff --git a/test/index.test.js b/test/index.test.js index 3d7ca1f..eb75449 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -5,7 +5,8 @@ import { Hono } from 'hono' import { commandFromArgv, adaptAndFetch, - listRoutesWithExamplesFromOpenApi + listRoutesWithExamplesFromOpenApi, + listRoutesWithExamplesFromComments } from '../dist/index.js' // commandFromArgv tests @@ -93,3 +94,52 @@ test('listRoutesWithExamplesFromOpenApi extracts params', () => { '--age (integer, required) : user age' ]) }) + +test('listRoutesWithExamplesFromComments extracts params', () => { + const file = new URL('./fixtures/commentUser.ts', import.meta.url).pathname + const { routes, examples, params } = listRoutesWithExamplesFromComments([file], 'cmd') + + assert.deepEqual(routes, ['/user/:id']) + assert.deepEqual(examples, ['cmd user --email --age ']) + assert.deepEqual(params, [ + [ + { name: 'id', in: 'path', required: true, description: 'user id' }, + { name: 'email', in: 'query', required: true, description: 'user email' }, + { name: 'age', in: 'body', required: false, description: 'user age' } + ] + ]) +}) + +test('listRoutesWithExamplesFromOpenApi merges comment routes', () => { + const commentFile = new URL('./fixtures/commentExtra.ts', import.meta.url).pathname + const openapi = { + paths: { + '/user/{id}': { + parameters: [ + { name: 'id', in: 'path', required: true, description: 'user id', schema: { type: 'string' } } + ], + post: { + parameters: [ + { name: 'email', in: 'query', required: true, description: 'user email', schema: { type: 'string' } } + ] + } + } + } + } + + const { routes, examples, params } = listRoutesWithExamplesFromOpenApi(openapi, 'cmd', [commentFile]) + + assert.deepEqual(routes, ['/user/:id', '/extra/:slug']) + assert.deepEqual(examples, ['cmd user --email ', 'cmd extra --token ']) + assert.deepEqual(params, [ + [ + { name: 'id', in: 'path', required: true, description: 'user id', schema: { type: 'string' } }, + { name: 'email', in: 'query', required: true, description: 'user email', schema: { type: 'string' } } + ], + [ + { name: 'slug', in: 'path', required: true, description: 'slug id' }, + { name: 'token', in: 'query', required: true, description: 'access token' } + ] + ]) +}) +