diff --git a/.changeset/quiet-headers-smile.md b/.changeset/quiet-headers-smile.md new file mode 100644 index 0000000..30b7762 --- /dev/null +++ b/.changeset/quiet-headers-smile.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Generated OpenAPI commands accepted header parameters and header security schemes as CLI options. diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts index 9bdf996..50ad108 100644 --- a/src/Openapi.test.ts +++ b/src/Openapi.test.ts @@ -203,6 +203,69 @@ describe('cli integration', () => { }) } + function createSecurityHeaderCli() { + return Cli.create('test', { description: 'test' }).command('api', { + fetch(request) { + return Response.json({ apiKey: request.headers.get('x-api-key') }) + }, + openapi: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + components: { + securitySchemes: { + tokenAuth: { + type: 'apiKey', + in: 'header', + name: 'x-api-key', + description: 'Access token', + }, + }, + }, + security: [{ tokenAuth: [] }], + paths: { + '/secret': { + get: { + operationId: 'getSecret', + summary: 'Get secret', + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }, + }) + } + + function createBearerAuthCli() { + return Cli.create('test', { description: 'test' }).command('api', { + fetch(request) { + return Response.json({ authorization: request.headers.get('authorization') }) + }, + openapi: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'Bearer credential', + }, + }, + }, + security: [{ bearerAuth: [] }], + paths: { + '/secret': { + get: { + operationId: 'getSecret', + summary: 'Get secret', + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }, + }) + } + test('GET /users via operationId', async () => { const { output } = await serve(createCli(), ['api', 'listUsers']) expect(output).toContain('Alice') @@ -230,6 +293,122 @@ describe('cli integration', () => { expect(json(output).limit).toBe(5) }) + test('security headers become generated command options', async () => { + const cli = createSecurityHeaderCli() + const { output } = await serve(cli, [ + 'api', + 'getSecret', + '--x-api-key', + 'secret', + '--format', + 'json', + ]) + + expect(json(output).apiKey).toMatchInlineSnapshot(`"secret"`) + }) + + test('security header options appear in generated command help', async () => { + const { output } = await serve(createSecurityHeaderCli(), ['api', 'getSecret', '--help']) + + expect(output).toMatchInlineSnapshot(` + "test api getSecret — Get secret + + Usage: test api getSecret [options] + + Options: + --x-api-key Access token + + Global Options: + --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) + --format Output format + --full-output Show full output envelope + --help Show help + --llms, --llms-full Print LLM-readable manifest + --schema Show JSON Schema for command + --token-count Print token count of output (instead of output) + --token-limit Limit output to n tokens + --token-offset Skip first n tokens of output + " + `) + }) + + test('bearer auth becomes an authorization option', async () => { + const { output } = await serve(createBearerAuthCli(), [ + 'api', + 'getSecret', + '--authorization', + 'Bearer secret', + '--format', + 'json', + ]) + + expect(json(output).authorization).toMatchInlineSnapshot(`"Bearer secret"`) + }) + + test('bearer auth option appears in generated command help', async () => { + const { output } = await serve(createBearerAuthCli(), ['api', 'getSecret', '--help']) + + expect(output).toMatchInlineSnapshot(` + "test api getSecret — Get secret + + Usage: test api getSecret [options] + + Options: + --authorization Bearer credential + + Global Options: + --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) + --format Output format + --full-output Show full output envelope + --help Show help + --llms, --llms-full Print LLM-readable manifest + --schema Show JSON Schema for command + --token-count Print token count of output (instead of output) + --token-limit Limit output to n tokens + --token-offset Skip first n tokens of output + " + `) + }) + + test('header parameters become generated command options', async () => { + const cli = Cli.create('test', { description: 'test' }).command('api', { + fetch(request) { + return Response.json({ requestId: request.headers.get('x-request-id') }) + }, + openapi: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/secret': { + get: { + operationId: 'getSecret', + summary: 'Get secret', + parameters: [ + { + name: 'x-request-id', + in: 'header', + schema: { type: 'string' }, + description: 'Request ID', + }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }, + }) + const { output } = await serve(cli, [ + 'api', + 'getSecret', + '--x-request-id', + 'request_test', + '--format', + 'json', + ]) + + expect(json(output).requestId).toMatchInlineSnapshot(`"request_test"`) + }) + test('loads OpenAPI commands from a spec URL string', async () => { const cli = Cli.create('test', { description: 'test' }).command('api', { fetch: app.fetch, diff --git a/src/Openapi.ts b/src/Openapi.ts index 0a862d8..a2d73d6 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -12,7 +12,17 @@ import { dereference } from './internal/dereference.js' import * as Schema from './Schema.js' /** A minimal OpenAPI 3.x spec shape. Accepts both hand-written specs and generated ones (e.g. from `@hono/zod-openapi`). */ -export type OpenAPISpec = { paths?: {} | undefined } +export type OpenAPISpec = { + components?: + | { + securitySchemes?: Record | undefined + } + | undefined + info?: Record | undefined + openapi?: string | undefined + paths?: {} | undefined + security?: readonly SecurityRequirement[] | undefined +} /** OpenAPI document source accepted by fetch-backed CLI commands. */ export type OpenAPISource = OpenAPISpec | string | URL @@ -72,6 +82,7 @@ type Operation = { parameters?: readonly Parameter[] | undefined requestBody?: RequestBody | undefined responses?: Record | undefined + security?: readonly SecurityRequirement[] | undefined summary?: string | undefined } @@ -88,6 +99,20 @@ type RequestBody = { required?: boolean | undefined } +type SecurityRequirement = Record + +type SecurityScheme = { + description?: string | undefined + in?: 'cookie' | 'header' | 'query' | undefined + name?: string | undefined + scheme?: string | undefined + type?: string | undefined +} + +type HeaderParameter = Parameter & { + optionName: string +} + /** A fetch handler. */ type FetchHandler = (req: Request) => Response | Promise @@ -356,6 +381,10 @@ export async function generateCommands( const pathParams = (op.parameters ?? []).filter((p) => p.in === 'path') const queryParams = (op.parameters ?? []).filter((p) => p.in === 'query') + const headerParams = headerOptions([ + ...(op.parameters ?? []).filter((p) => p.in === 'header'), + ...securityHeaderParams(resolved, op), + ]) const bodySchema = op.requestBody?.content?.['application/json']?.schema const bodyProps = (bodySchema?.properties ?? {}) as Record> @@ -376,16 +405,28 @@ export async function generateCommands( // Build options Zod schema from query params + body properties const optShape: Record = {} + const usedOptionNames = new Set() for (const p of queryParams) { let zodType = p.schema ? toZod(p.schema) : z.string() if (!p.required) zodType = zodType.optional() if (p.description) zodType = zodType.describe(p.description) optShape[p.name] = coerceIfNeeded(zodType) + usedOptionNames.add(p.name) } for (const [key, schema] of Object.entries(bodyProps)) { let zodType = toZod(schema) if (!bodyRequired.has(key)) zodType = zodType.optional() optShape[key] = zodType + usedOptionNames.add(key) + } + for (const p of headerParams) { + const optionName = resolveHeaderOptionName(p.optionName, usedOptionNames) + p.optionName = optionName + let zodType = p.schema ? toZod(p.schema) : z.string() + if (!p.required) zodType = zodType.optional() + zodType = zodType.describe(p.description ?? `${p.name} header`) + optShape[optionName] = coerceIfNeeded(zodType) + usedOptionNames.add(optionName) } const optionsSchema = Object.keys(optShape).length > 0 ? z.object(optShape) : undefined @@ -398,6 +439,7 @@ export async function generateCommands( fetch, httpMethod, path, + headerParams, pathParams, queryParams, bodyProps, @@ -438,6 +480,77 @@ function openapiOperations(paths: Record>) { return operations } +function securityHeaderParams(spec: OpenAPISpec, operation: Operation): Parameter[] { + const schemes = spec.components?.securitySchemes ?? {} + const requirements = operation.security ?? spec.security ?? [] + const headers: Parameter[] = [] + + for (const requirement of requirements) + for (const name of Object.keys(requirement)) { + const scheme = schemes[name] + const parameter = securityHeaderParam(name, scheme) + if (parameter) headers.push(parameter) + } + + return headers +} + +function securityHeaderParam( + name: string, + scheme: SecurityScheme | undefined, +): Parameter | undefined { + if (!scheme) return undefined + // `apiKey` is OpenAPI's generic name for a credential carried in a + // header/query/cookie, not an incur- or Cadent-specific API key concept. + if (scheme.type === 'apiKey' && scheme.in === 'header' && scheme.name) + return { + description: scheme.description ?? `${scheme.name} header`, + in: 'header', + name: scheme.name, + required: false, + schema: { type: 'string' }, + } + + if (scheme.type === 'http' && authorizationSchemes.has(scheme.scheme?.toLowerCase() ?? '')) + return { + description: scheme.description ?? `${name} authorization header`, + in: 'header', + name: 'authorization', + required: false, + schema: { type: 'string' }, + } + + return undefined +} + +const authorizationSchemes = new Set(['basic', 'bearer']) + +function headerOptions(parameters: Parameter[]): HeaderParameter[] { + const seen = new Set() + const headers: HeaderParameter[] = [] + + for (const parameter of parameters) { + const normalized = parameter.name.toLowerCase() + if (seen.has(normalized)) continue + seen.add(normalized) + headers.push({ ...parameter, optionName: normalized }) + } + + return headers +} + +function resolveHeaderOptionName(optionName: string, used: Set) { + if (!used.has(optionName)) return optionName + + const prefix = `header-${optionName}` + if (!used.has(prefix)) return prefix + + for (let index = 2; ; index++) { + const candidate = `${prefix}-${index}` + if (!used.has(candidate)) return candidate + } +} + function getNamespaceInfo(operations: OperationEntry[]) { const pathOperations = new Map() const parentPaths = new Set() @@ -565,6 +678,7 @@ function createHandler(config: { basePath?: string | undefined bodyProps: Record> fetch: FetchHandler + headerParams: HeaderParameter[] httpMethod: string path: string pathParams: Parameter[] @@ -604,7 +718,13 @@ function createHandler(config: { query, } - if (body) input.headers.set('content-type', 'application/json') + for (const p of config.headerParams) { + const value = options[p.optionName] + if (value !== undefined) input.headers.set(p.name, String(value)) + } + + if (body && !input.headers.has('content-type')) + input.headers.set('content-type', 'application/json') const request = Fetch.buildRequest(input) const response = await config.fetch(request)