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/wise-hosts-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"incur": patch
---

Added hosted OpenAPI command generation from `Fetch.fromRequest` sources.
45 changes: 44 additions & 1 deletion src/Cli.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Cli, middleware, z } from 'incur'
import { Cli, Fetch, middleware, z } from 'incur'
import type { MiddlewareHandler } from 'incur'
import { expectTypeOf, test } from 'vitest'

Expand Down Expand Up @@ -38,6 +38,49 @@ test('without schemas, run receives empty objects', () => {
})
})

test('fetch command accepts OpenAPI object and URL sources', () => {
const cli = Cli.create('test')
const fetch = () => new Response()

cli.command('apiJson', {
fetch,
openapi: { paths: {} },
})

cli.command('apiString', {
fetch,
openapi: 'https://api.example.com/openapi.json',
})

cli.command('apiUrl', {
fetch,
openapi: new URL('https://api.example.com/openapi.json'),
})

cli.command('hostedApi', {
fetch: Fetch.fromRequest('https://api.example.com'),
openapi: 'openapi.json',
})
})

test('root fetch accepts hosted request sources with OpenAPI paths', () => {
Cli.create('test', {
fetch: Fetch.fromRequest('https://api.example.com'),
openapi: '/openapi.json',
})

Cli.create('test', {
fetch: Fetch.fromRequest(new URL('https://api.example.com')),
openapi: new URL('https://api.example.com/openapi.json'),
})

Cli.create('test', {
// @ts-expect-error -- hosted fetches must use Fetch.fromRequest.
fetch: 'https://api.example.com',
openapi: '/openapi.json',
})
})

test('output constrains run return type', () => {
const cli = Cli.create('test')
cli.command('greet', {
Expand Down
89 changes: 70 additions & 19 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export type Cli<
definition: {
basePath?: string | undefined
description?: string | undefined
fetch: FetchHandler
openapi?: Openapi.OpenAPISpec | undefined
fetch: FetchSource
openapi?: Openapi.OpenAPISource | undefined
outputPolicy?: OutputPolicy | undefined
},
): Cli<commands, vars, env>
Expand Down Expand Up @@ -205,13 +205,26 @@ export function create(
const name = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name
const def = typeof nameOrDefinition === 'string' ? (definition ?? {}) : nameOrDefinition
const rootDef = 'run' in def ? (def as CommandDefinition<any, any, any>) : undefined
const rootFetch = 'fetch' in def ? (def.fetch as FetchHandler) : undefined
const rootFetchSource =
'fetch' in def && def.fetch !== undefined ? (def.fetch as FetchSource) : undefined
const rootFetch = rootFetchSource === undefined ? undefined : resolveFetch(rootFetchSource)
const rootFetchBaseUrl = rootFetchSource === undefined ? undefined : fetchBaseUrl(rootFetchSource)

const commands = new Map<string, CommandEntry>()
const middlewares: MiddlewareHandler[] = []
const pending: Promise<void>[] = []
const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0')

if (def.openapi && rootFetch) {
pending.push(
(async () => {
const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl })
const generated = await Openapi.generateCommands(spec, rootFetch)
for (const [name, command] of generated) commands.set(name, command)
})(),
)
}

const cli: Cli = {
name,
description: def.description,
Expand All @@ -220,28 +233,33 @@ export function create(

command(nameOrCli: any, def?: any): any {
if (typeof nameOrCli === 'string') {
if (def && 'fetch' in def && typeof def.fetch === 'function') {
if (def && 'fetch' in def && isFetchSource(def.fetch)) {
const fetch = resolveFetch(def.fetch)
// OpenAPI + fetch → generate typed command group (async, resolved before serve)
if (def.openapi) {
pending.push(
Openapi.generateCommands(def.openapi, def.fetch, { basePath: def.basePath }).then(
(generated) => {
commands.set(nameOrCli, {
_group: true,
description: def.description,
commands: generated as Map<string, CommandEntry>,
...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
} as InternalGroup)
},
),
(async () => {
const spec = await Openapi.resolve(def.openapi, {
baseUrl: fetchBaseUrl(def.fetch),
})
const generated = await Openapi.generateCommands(spec, fetch, {
basePath: def.basePath,
})
commands.set(nameOrCli, {
_group: true,
description: def.description,
commands: generated as Map<string, CommandEntry>,
...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
} as InternalGroup)
})(),
)
return cli
}
commands.set(nameOrCli, {
_fetch: true,
basePath: def.basePath,
description: def.description,
fetch: def.fetch,
fetch,
...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
} as InternalFetchGateway)
return cli
Expand Down Expand Up @@ -364,8 +382,10 @@ export declare namespace create {
env?: env | undefined
/** Usage examples for this command. */
examples?: Example<args, options>[] | undefined
/** A fetch handler to use as the root command. All argv tokens are interpreted as path segments and curl-style flags. */
fetch?: FetchHandler | undefined
/** A fetch handler or hosted fetch source to use as the root command. All argv tokens are interpreted as path segments and curl-style flags. */
fetch?: FetchSource | undefined
/** OpenAPI spec source used to generate typed root commands for the root fetch handler. */
openapi?: Openapi.OpenAPISource | undefined
/** Default output format. Overridden by `--format` or `--json`. */
format?: Formatter.Format | undefined
/** Zod schema for named options/flags. */
Expand Down Expand Up @@ -960,7 +980,18 @@ async function serveImpl(
// --help on a fetch gateway → show fetch-specific help
if (help && 'fetchGateway' in resolved) {
const commandName = resolved.path === name ? name : `${name} ${resolved.path}`
writeln(formatFetchHelp(commandName, resolved.fetchGateway.description))
if (resolved.path === name && commands.size > 0)
writeln(
Help.formatRoot(name, {
aliases: options.aliases,
configFlag,
description: options.description,
version: options.version,
commands: collectHelpCommands(commands),
root: true,
}),
)
else writeln(formatFetchHelp(commandName, resolved.fetchGateway.description))
return
}

Expand Down Expand Up @@ -2420,7 +2451,10 @@ type CommandEntry =
export type OutputPolicy = 'agent-only' | 'all'

/** A standard Fetch API handler. */
type FetchHandler = (req: Request) => Response | Promise<Response>
export type FetchHandler = Fetch.Handler

/** Fetch handler or hosted source used by fetch-backed commands. */
export type FetchSource = Fetch.Source

/** @internal A command group's internal storage. */
type InternalGroup = {
Expand All @@ -2440,6 +2474,23 @@ type InternalFetchGateway = {
outputPolicy?: OutputPolicy | undefined
}

function isFetchSource(value: unknown): value is FetchSource {
if (typeof value === 'function') return true
if (typeof value !== 'object' || value === null) return false

const source = value as { fetch?: unknown; url?: unknown }
return typeof source.fetch === 'function' && source.url instanceof URL
}

function resolveFetch(source: FetchSource): FetchHandler {
if (typeof source === 'function') return source
return source.fetch
}

function fetchBaseUrl(source: FetchSource) {
return typeof source === 'function' ? undefined : source.url
}

/** @internal Type guard for command groups. */
function isGroup(entry: CommandEntry): entry is InternalGroup {
return '_group' in entry
Expand Down
64 changes: 63 additions & 1 deletion src/Fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,70 @@
import { describe, expect, test } from 'vitest'
import { describe, expect, test, vi } from 'vitest'

import { app } from '../test/fixtures/hono-api.js'
import * as Fetch from './Fetch.js'

describe('fromRequest', () => {
test('forwards requests to the hosted base URL', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
expect(request.url).toMatchInlineSnapshot(`"https://api.example.com/api/users?limit=5"`)
expect(request.method).toMatchInlineSnapshot(`"GET"`)
expect(request.headers.get('authorization')).toMatchInlineSnapshot(`"Bearer token"`)
expect(request.headers.get('x-api-key')).toMatchInlineSnapshot(`"key_test"`)
return Response.json({ ok: true })
})

try {
const source = Fetch.fromRequest('https://api.example.com/api', {
headers: { authorization: 'Bearer token' },
})
const response = await source.fetch(
new Request('http://localhost/users?limit=5', {
headers: { 'x-api-key': 'key_test' },
}),
)

expect(await response.json()).toMatchInlineSnapshot(`
{
"ok": true,
}
`)
} finally {
fetch.mockRestore()
}
})

test('preserves request method and body', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
expect(request.url).toMatchInlineSnapshot(`"https://api.example.com/api/users"`)
expect(request.method).toMatchInlineSnapshot(`"POST"`)
expect(request.headers.get('content-type')).toMatchInlineSnapshot(`"application/json"`)
expect(await request.text()).toMatchInlineSnapshot(`"{"name":"Ada"}"`)
return Response.json({ created: true })
})

try {
const source = Fetch.fromRequest(new URL('https://api.example.com/api'))
const response = await source.fetch(
new Request('http://localhost/users', {
body: '{"name":"Ada"}',
headers: { 'content-type': 'application/json' },
method: 'POST',
}),
)

expect(await response.json()).toMatchInlineSnapshot(`
{
"created": true,
}
`)
} finally {
fetch.mockRestore()
}
})
})

describe('parseArgv', () => {
test('bare tokens → path segments', () => {
const input = Fetch.parseArgv(['users', 'list'])
Expand Down
49 changes: 49 additions & 0 deletions src/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,59 @@ export type FetchOutput = {
status: number
}

/** A standard Fetch API handler. */
export type Handler = (req: Request) => Response | Promise<Response>

/** Fetch source accepted by fetch-backed CLI commands. */
export type Source = Handler | RequestSource

/** Hosted request source created from a base URL. */
export type RequestSource = {
/** Handles a forwarded request. */
fetch: Handler
/** Base URL used to resolve relative OpenAPI documents. */
url: URL
}

/** Reserved flags consumed by the fetch gateway (not forwarded as query params). */
const reservedFlags = new Set(['method', 'body', 'data', 'header'])
const reservedShort: Record<string, string> = { X: 'method', d: 'data', H: 'header' }

/** Creates a hosted fetch source from a base request URL and shared request options. */
export function fromRequest(url: string | URL, options: fromRequest.Options = {}): RequestSource {
const base = new URL(url)
const { headers: defaultHeaders, ...init } = options

return {
url: base,
fetch(request) {
const incoming = new URL(request.url)
const target = new URL(base)
target.pathname = joinPath(base.pathname, incoming.pathname)
target.search = incoming.search

const headers = new Headers(defaultHeaders)
request.headers.forEach((value, key) => headers.set(key, value))

return fetch(new Request(new Request(target, request), { ...init, headers }))
},
}
}

export declare namespace fromRequest {
/** Request options applied to every forwarded request. */
type Options = Omit<RequestInit, 'body' | 'headers' | 'method'> & {
/** Headers merged into every forwarded request. Per-request headers take precedence. */
headers?: HeadersInit | undefined
}
}

function joinPath(basePath: string, path: string) {
const prefix = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath
const suffix = path.startsWith('/') ? path : `/${path}`
return `${prefix}${suffix}` || '/'
}

/** Parses curl-style argv into a structured fetch input. */
export function parseArgv(argv: string[]): FetchInput {
const segments: string[] = []
Expand Down
Loading
Loading