Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/quiet-headers-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': patch
---

Generated OpenAPI commands accepted header parameters and header security schemes as CLI options.
179 changes: 179 additions & 0 deletions src/Openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 <string> Access token

Global Options:
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
--format <toon|json|yaml|md|jsonl> 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 <n> Limit output to n tokens
--token-offset <n> 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 <string> Bearer credential

Global Options:
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
--format <toon|json|yaml|md|jsonl> 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 <n> Limit output to n tokens
--token-offset <n> 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,
Expand Down
124 changes: 122 additions & 2 deletions src/Openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SecurityScheme> | undefined
}
| undefined
info?: Record<string, unknown> | 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
Expand Down Expand Up @@ -72,6 +82,7 @@ type Operation = {
parameters?: readonly Parameter[] | undefined
requestBody?: RequestBody | undefined
responses?: Record<string, unknown> | undefined
security?: readonly SecurityRequirement[] | undefined
summary?: string | undefined
}

Expand All @@ -88,6 +99,20 @@ type RequestBody = {
required?: boolean | undefined
}

type SecurityRequirement = Record<string, readonly string[]>

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<Response>

Expand Down Expand Up @@ -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<string, Record<string, unknown>>
Expand All @@ -376,16 +405,28 @@ export async function generateCommands(

// Build options Zod schema from query params + body properties
const optShape: Record<string, z.ZodType> = {}
const usedOptionNames = new Set<string>()
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

Expand All @@ -398,6 +439,7 @@ export async function generateCommands(
fetch,
httpMethod,
path,
headerParams,
pathParams,
queryParams,
bodyProps,
Expand Down Expand Up @@ -438,6 +480,77 @@ function openapiOperations(paths: Record<string, Record<string, unknown>>) {
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<string>()
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<string>) {
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<string, number>()
const parentPaths = new Set<string>()
Expand Down Expand Up @@ -565,6 +678,7 @@ function createHandler(config: {
basePath?: string | undefined
bodyProps: Record<string, Record<string, unknown>>
fetch: FetchHandler
headerParams: HeaderParameter[]
httpMethod: string
path: string
pathParams: Parameter[]
Expand Down Expand Up @@ -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)
Expand Down
Loading