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

Added `openapiConfig.mode` for choosing operation id or namespace command generation.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,25 @@ $ my-cli api createUser --name Bob
# → name: Bob
```

Set `openapiConfig.mode` to `'namespace'` to generate nested commands from path segments instead of using `operationId`:

```ts
Cli.create('my-cli', { description: 'My CLI' })
.command('api', { fetch: app.fetch, openapi: spec, openapiConfig: { mode: 'namespace' } })
.serve()
```

```sh
$ my-cli api users --help
# Commands:
# get List users
# id User ID
# post Create a user

$ my-cli api users get --limit 5
# → users: ...
```

When served with `cli.fetch`, the generated spec is available at `/openapi.json`, `/openapi.yml`, `/openapi.yaml`, and `/.well-known/openapi.json`. Methods are inferred from command names: read-like commands use `GET`, update-like commands use `PATCH`, delete-like commands use `DELETE`, and other commands use `POST`.

### Serve CLIs as APIs
Expand Down
18 changes: 18 additions & 0 deletions src/Cli.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,43 @@ test('fetch command accepts OpenAPI object and URL sources', () => {
cli.command('hostedApi', {
fetch: Fetch.fromRequest('https://api.example.com'),
openapi: 'openapi.json',
openapiConfig: { mode: 'namespace' },
})

cli.command('apiOperationMode', {
fetch,
openapi: { paths: {} },
openapiConfig: { mode: 'operation' },
})
})

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

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

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

Cli.create('test', {
fetch: Fetch.fromRequest('https://api.example.com'),
openapi: '/openapi.json',
openapiConfig: {
// @ts-expect-error -- OpenAPI mode must be namespace or operation.
mode: 'path',
},
})
})

test('output constrains run return type', () => {
Expand Down
8 changes: 7 additions & 1 deletion src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type Cli<
description?: string | undefined
fetch: FetchSource
openapi?: Openapi.OpenAPISource | undefined
openapiConfig?: Openapi.Config | undefined
outputPolicy?: OutputPolicy | undefined
},
): Cli<commands, vars, env>
Expand Down Expand Up @@ -219,7 +220,9 @@ export function create(
pending.push(
(async () => {
const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl })
const generated = await Openapi.generateCommands(spec, rootFetch)
const generated = await Openapi.generateCommands(spec, rootFetch, {
config: def.openapiConfig,
})
for (const [name, command] of generated) commands.set(name, command)
})(),
)
Expand All @@ -244,6 +247,7 @@ export function create(
})
const generated = await Openapi.generateCommands(spec, fetch, {
basePath: def.basePath,
config: def.openapiConfig,
})
commands.set(nameOrCli, {
_group: true,
Expand Down Expand Up @@ -386,6 +390,8 @@ export declare namespace create {
fetch?: FetchSource | undefined
/** OpenAPI spec source used to generate typed root commands for the root fetch handler. */
openapi?: Openapi.OpenAPISource | undefined
/** Configuration for generated OpenAPI commands. */
openapiConfig?: Openapi.Config | undefined
/** Default output format. Overridden by `--format` or `--json`. */
format?: Formatter.Format | undefined
/** Zod schema for named options/flags. */
Expand Down
44 changes: 44 additions & 0 deletions src/Openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,49 @@ describe('generateCommands', () => {
test('command has description from summary', async () => {
const commands = await Openapi.generateCommands(spec, app.fetch)
const cmd = commands.get('listUsers')!
if ('_group' in cmd) throw new Error('expected listUsers command')
expect(cmd.description).toBe('List users')
})

test('coerced number params preserve description', async () => {
const commands = await Openapi.generateCommands(spec, app.fetch)
const cmd = commands.get('listUsers')!
if ('_group' in cmd) throw new Error('expected listUsers command')
const limitSchema = cmd.options!.shape.limit
expect(limitSchema.description).toBe('Max results')
})

test('generates namespace command groups from paths', async () => {
const commands = await Openapi.generateCommands(spec, app.fetch, {
config: { mode: 'namespace' },
})
expect([...commands.keys()].sort()).toMatchInlineSnapshot(`
[
"health",
"users",
]
`)

const users = commands.get('users')!
expect('_group' in users).toMatchInlineSnapshot(`true`)
expect('_group' in users ? users.description : undefined).toMatchInlineSnapshot(`"List users"`)
expect('_group' in users ? [...users.commands.keys()].sort() : []).toMatchInlineSnapshot(`
[
"get",
"id",
"post",
]
`)

const id = '_group' in users ? users.commands.get('id')! : undefined
expect(id && '_group' in id ? id.description : undefined).toMatchInlineSnapshot(`"User ID"`)
expect(id && '_group' in id ? [...id.commands.keys()].sort() : []).toMatchInlineSnapshot(`
[
"delete",
"get",
]
`)
})
})

describe('cli integration', () => {
Expand All @@ -174,6 +208,16 @@ describe('cli integration', () => {
expect(output).toContain('Alice')
})

test('GET /users via namespace', async () => {
const cli = Cli.create('test', { description: 'test' }).command('api', {
fetch: app.fetch,
openapi: spec,
openapiConfig: { mode: 'namespace' },
})
const { output } = await serve(cli, ['api', 'users', 'get', '--limit', '5', '--format', 'json'])
expect(json(output).limit).toMatchInlineSnapshot(`5`)
})

test('GET /users?limit=5 via options', async () => {
const { output } = await serve(createCli(), [
'api',
Expand Down
Loading
Loading