incur 0.1.17 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/SKILL.md +42 -0
- package/dist/Cli.d.ts +23 -2
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +222 -22
- package/dist/Cli.js.map +1 -1
- package/dist/Fetch.d.ts +26 -0
- package/dist/Fetch.d.ts.map +1 -0
- package/dist/Fetch.js +150 -0
- package/dist/Fetch.js.map +1 -0
- package/dist/Openapi.d.ts +20 -0
- package/dist/Openapi.d.ts.map +1 -0
- package/dist/Openapi.js +136 -0
- package/dist/Openapi.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/Cli.test-d.ts +2 -2
- package/src/Cli.test.ts +205 -0
- package/src/Cli.ts +258 -23
- package/src/Fetch.test.ts +274 -0
- package/src/Fetch.ts +170 -0
- package/src/Openapi.test.ts +320 -0
- package/src/Openapi.ts +196 -0
- package/src/e2e.test.ts +175 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import * as Cli from './Cli.js'
|
|
4
|
+
import * as Openapi from './Openapi.js'
|
|
5
|
+
import { app } from '../test/fixtures/hono-api.js'
|
|
6
|
+
import { app as prefixedApp } from '../test/fixtures/hono-api-prefixed.js'
|
|
7
|
+
import { spec } from '../test/fixtures/openapi-spec.js'
|
|
8
|
+
import { app as openapiApp, spec as openapiSpec } from '../test/fixtures/hono-openapi-app.js'
|
|
9
|
+
|
|
10
|
+
function serve(cli: { serve: Cli.Cli['serve'] }, argv: string[]) {
|
|
11
|
+
let output = ''
|
|
12
|
+
let exitCode: number | undefined
|
|
13
|
+
return cli
|
|
14
|
+
.serve(argv, {
|
|
15
|
+
stdout: (s) => (output += s),
|
|
16
|
+
exit: (c) => { exitCode = c },
|
|
17
|
+
})
|
|
18
|
+
.then(() => ({
|
|
19
|
+
output,
|
|
20
|
+
exitCode,
|
|
21
|
+
}))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function json(output: string) {
|
|
25
|
+
return JSON.parse(
|
|
26
|
+
output.replace(/"duration": "[^"]+"/g, '"duration": "<stripped>"'),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('generateCommands', () => {
|
|
31
|
+
test('generates command entries from spec', async () => {
|
|
32
|
+
const commands = await Openapi.generateCommands(spec, app.fetch)
|
|
33
|
+
expect(commands.has('listUsers')).toBe(true)
|
|
34
|
+
expect(commands.has('createUser')).toBe(true)
|
|
35
|
+
expect(commands.has('getUser')).toBe(true)
|
|
36
|
+
expect(commands.has('deleteUser')).toBe(true)
|
|
37
|
+
expect(commands.has('healthCheck')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('command has description from summary', async () => {
|
|
41
|
+
const commands = await Openapi.generateCommands(spec, app.fetch)
|
|
42
|
+
const cmd = commands.get('listUsers')!
|
|
43
|
+
expect(cmd.description).toBe('List users')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('cli integration', () => {
|
|
48
|
+
function createCli() {
|
|
49
|
+
return Cli.create('test', { description: 'test' })
|
|
50
|
+
.command('api', { fetch: app.fetch, openapi: spec })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('GET /users via operationId', async () => {
|
|
54
|
+
const { output } = await serve(createCli(), ['api', 'listUsers'])
|
|
55
|
+
expect(output).toContain('Alice')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('GET /users?limit=5 via options', async () => {
|
|
59
|
+
const { output } = await serve(createCli(), [
|
|
60
|
+
'api', 'listUsers', '--limit', '5', '--format', 'json',
|
|
61
|
+
])
|
|
62
|
+
expect(json(output).limit).toBe(5)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('GET /users/:id via positional arg', async () => {
|
|
66
|
+
const { output } = await serve(createCli(), ['api', 'getUser', '42'])
|
|
67
|
+
expect(output).toMatchInlineSnapshot(`
|
|
68
|
+
"id: 42
|
|
69
|
+
name: Alice
|
|
70
|
+
"
|
|
71
|
+
`)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('POST /users via createUser with body options', async () => {
|
|
75
|
+
const { output } = await serve(createCli(), ['api', 'createUser', '--name', 'Bob'])
|
|
76
|
+
expect(output).toMatchInlineSnapshot(`
|
|
77
|
+
"created: true
|
|
78
|
+
name: Bob
|
|
79
|
+
"
|
|
80
|
+
`)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('DELETE /users/:id via deleteUser', async () => {
|
|
84
|
+
const { output } = await serve(createCli(), ['api', 'deleteUser', '1'])
|
|
85
|
+
expect(output).toMatchInlineSnapshot(`
|
|
86
|
+
"deleted: true
|
|
87
|
+
id: 1
|
|
88
|
+
"
|
|
89
|
+
`)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('GET /health via healthCheck', async () => {
|
|
93
|
+
const { output } = await serve(createCli(), ['api', 'healthCheck'])
|
|
94
|
+
expect(output).toMatchInlineSnapshot(`
|
|
95
|
+
"ok: true
|
|
96
|
+
"
|
|
97
|
+
`)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('--help on api shows subcommands', async () => {
|
|
101
|
+
const { output } = await serve(createCli(), ['api', '--help'])
|
|
102
|
+
expect(output).toContain('listUsers')
|
|
103
|
+
expect(output).toContain('createUser')
|
|
104
|
+
expect(output).toContain('getUser')
|
|
105
|
+
expect(output).toContain('deleteUser')
|
|
106
|
+
expect(output).toContain('healthCheck')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('--help on specific command shows typed args/options', async () => {
|
|
110
|
+
const { output } = await serve(createCli(), ['api', 'getUser', '--help'])
|
|
111
|
+
expect(output).toContain('id')
|
|
112
|
+
expect(output).toContain('Get a user by ID')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('--help on createUser shows body options', async () => {
|
|
116
|
+
const { output } = await serve(createCli(), ['api', 'createUser', '--help'])
|
|
117
|
+
expect(output).toContain('name')
|
|
118
|
+
expect(output).toContain('Create a user')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('--format json', async () => {
|
|
122
|
+
const { output } = await serve(createCli(), ['api', 'healthCheck', '--format', 'json'])
|
|
123
|
+
expect(json(output)).toEqual({ ok: true })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('--verbose wraps in envelope', async () => {
|
|
127
|
+
const { output } = await serve(createCli(), [
|
|
128
|
+
'api', 'healthCheck', '--verbose', '--format', 'json',
|
|
129
|
+
])
|
|
130
|
+
const parsed = json(output)
|
|
131
|
+
expect(parsed.ok).toBe(true)
|
|
132
|
+
expect(parsed.data).toEqual({ ok: true })
|
|
133
|
+
expect(parsed.meta.command).toContain('api')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('missing required path param shows validation error', async () => {
|
|
137
|
+
const { exitCode } = await serve(createCli(), ['api', 'getUser'])
|
|
138
|
+
expect(exitCode).toBe(1)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('@hono/zod-openapi integration', () => {
|
|
143
|
+
function createCli() {
|
|
144
|
+
return Cli.create('test', { description: 'test' })
|
|
145
|
+
.command('api', { fetch: openapiApp.fetch, openapi: openapiSpec })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
test('GET /users via listUsers', async () => {
|
|
149
|
+
const { output } = await serve(createCli(), ['api', 'listUsers'])
|
|
150
|
+
expect(output).toContain('Alice')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('GET /users?limit=5', async () => {
|
|
154
|
+
const { output } = await serve(createCli(), ['api', 'listUsers', '--limit', '5', '--format', 'json'])
|
|
155
|
+
expect(json(output).limit).toBe(5)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('GET /users/:id via getUser', async () => {
|
|
159
|
+
const { output } = await serve(createCli(), ['api', 'getUser', '42'])
|
|
160
|
+
expect(output).toMatchInlineSnapshot(`
|
|
161
|
+
"id: 42
|
|
162
|
+
name: Alice
|
|
163
|
+
"
|
|
164
|
+
`)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('POST /users via createUser', async () => {
|
|
168
|
+
const { output } = await serve(createCli(), ['api', 'createUser', '--name', 'Bob'])
|
|
169
|
+
expect(output).toMatchInlineSnapshot(`
|
|
170
|
+
"created: true
|
|
171
|
+
name: Bob
|
|
172
|
+
"
|
|
173
|
+
`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('DELETE /users/:id via deleteUser', async () => {
|
|
177
|
+
const { output } = await serve(createCli(), ['api', 'deleteUser', '1'])
|
|
178
|
+
expect(output).toMatchInlineSnapshot(`
|
|
179
|
+
"deleted: true
|
|
180
|
+
id: 1
|
|
181
|
+
"
|
|
182
|
+
`)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('GET /health via healthCheck', async () => {
|
|
186
|
+
const { output } = await serve(createCli(), ['api', 'healthCheck'])
|
|
187
|
+
expect(output).toMatchInlineSnapshot(`
|
|
188
|
+
"ok: true
|
|
189
|
+
"
|
|
190
|
+
`)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('--help shows operationId commands', async () => {
|
|
194
|
+
const { output } = await serve(createCli(), ['api', '--help'])
|
|
195
|
+
expect(output).toContain('listUsers')
|
|
196
|
+
expect(output).toContain('getUser')
|
|
197
|
+
expect(output).toContain('createUser')
|
|
198
|
+
expect(output).toContain('deleteUser')
|
|
199
|
+
expect(output).toContain('healthCheck')
|
|
200
|
+
expect(output).toContain('updateUser')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('--help on getUser shows path param', async () => {
|
|
204
|
+
const { output } = await serve(createCli(), ['api', 'getUser', '--help'])
|
|
205
|
+
expect(output).toContain('id')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('--help on createUser shows body options', async () => {
|
|
209
|
+
const { output } = await serve(createCli(), ['api', 'createUser', '--help'])
|
|
210
|
+
expect(output).toContain('name')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('--help on updateUser shows path param and body options', async () => {
|
|
214
|
+
const { output } = await serve(createCli(), ['api', 'updateUser', '--help'])
|
|
215
|
+
expect(output).toContain('id')
|
|
216
|
+
expect(output).toContain('name')
|
|
217
|
+
expect(output).toContain('Update a user')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('--format json', async () => {
|
|
221
|
+
const { output } = await serve(createCli(), ['api', 'healthCheck', '--format', 'json'])
|
|
222
|
+
expect(json(output)).toEqual({ ok: true })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('--verbose wraps in envelope', async () => {
|
|
226
|
+
const { output } = await serve(createCli(), [
|
|
227
|
+
'api', 'healthCheck', '--verbose', '--format', 'json',
|
|
228
|
+
])
|
|
229
|
+
const parsed = json(output)
|
|
230
|
+
expect(parsed.ok).toBe(true)
|
|
231
|
+
expect(parsed.data).toEqual({ ok: true })
|
|
232
|
+
expect(parsed.meta.command).toContain('api')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('missing required path param shows validation error', async () => {
|
|
236
|
+
const { exitCode } = await serve(createCli(), ['api', 'getUser'])
|
|
237
|
+
expect(exitCode).toBe(1)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('PUT /users/:id with path param + body options', async () => {
|
|
241
|
+
const { output } = await serve(createCli(), ['api', 'updateUser', '1', '--name', 'Updated'])
|
|
242
|
+
expect(output).toMatchInlineSnapshot(`
|
|
243
|
+
"id: 1
|
|
244
|
+
name: Updated
|
|
245
|
+
"
|
|
246
|
+
`)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('PUT /users/:id with optional boolean body option', async () => {
|
|
250
|
+
const { output } = await serve(createCli(), [
|
|
251
|
+
'api', 'updateUser', '1', '--name', 'Updated', '--active', 'true', '--format', 'json',
|
|
252
|
+
])
|
|
253
|
+
const parsed = json(output)
|
|
254
|
+
expect(parsed.id).toBe(1)
|
|
255
|
+
expect(parsed.name).toBe('Updated')
|
|
256
|
+
expect(parsed.active).toBe(true)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('query param coercion with zod-openapi generated spec', async () => {
|
|
260
|
+
const { output } = await serve(createCli(), ['api', 'listUsers', '--limit', '3', '--format', 'json'])
|
|
261
|
+
expect(json(output).limit).toBe(3)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('basePath', () => {
|
|
266
|
+
test('fetch gateway prepends basePath to request path', async () => {
|
|
267
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
268
|
+
.command('api', { fetch: prefixedApp.fetch, basePath: '/api' })
|
|
269
|
+
const { output } = await serve(cli, ['api', 'users'])
|
|
270
|
+
expect(output).toContain('Alice')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('fetch gateway basePath with query params', async () => {
|
|
274
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
275
|
+
.command('api', { fetch: prefixedApp.fetch, basePath: '/api' })
|
|
276
|
+
const { output } = await serve(cli, ['api', 'users', '--limit', '5', '--format', 'json'])
|
|
277
|
+
expect(json(output).limit).toBe(5)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('fetch gateway basePath with POST', async () => {
|
|
281
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
282
|
+
.command('api', { fetch: prefixedApp.fetch, basePath: '/api' })
|
|
283
|
+
const { output } = await serve(cli, ['api', 'users', '-X', 'POST', '-d', '{"name":"Bob"}'])
|
|
284
|
+
expect(output).toContain('Bob')
|
|
285
|
+
expect(output).toContain('created')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('openapi with basePath prepends to spec paths', async () => {
|
|
289
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
290
|
+
.command('api', { fetch: prefixedApp.fetch, openapi: spec, basePath: '/api' })
|
|
291
|
+
const { output } = await serve(cli, ['api', 'listUsers'])
|
|
292
|
+
expect(output).toContain('Alice')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('openapi basePath with path params', async () => {
|
|
296
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
297
|
+
.command('api', { fetch: prefixedApp.fetch, openapi: spec, basePath: '/api' })
|
|
298
|
+
const { output } = await serve(cli, ['api', 'getUser', '42'])
|
|
299
|
+
expect(output).toMatchInlineSnapshot(`
|
|
300
|
+
"id: 42
|
|
301
|
+
name: Alice
|
|
302
|
+
"
|
|
303
|
+
`)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('openapi basePath with body options', async () => {
|
|
307
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
308
|
+
.command('api', { fetch: prefixedApp.fetch, openapi: spec, basePath: '/api' })
|
|
309
|
+
const { output } = await serve(cli, ['api', 'createUser', '--name', 'Bob'])
|
|
310
|
+
expect(output).toContain('created')
|
|
311
|
+
expect(output).toContain('Bob')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('openapi basePath with health check', async () => {
|
|
315
|
+
const cli = Cli.create('test', { description: 'test' })
|
|
316
|
+
.command('api', { fetch: prefixedApp.fetch, openapi: spec, basePath: '/api' })
|
|
317
|
+
const { output } = await serve(cli, ['api', 'healthCheck', '--format', 'json'])
|
|
318
|
+
expect(json(output)).toEqual({ ok: true })
|
|
319
|
+
})
|
|
320
|
+
})
|
package/src/Openapi.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { dereference } from '@readme/openapi-parser'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import * as Fetch from './Fetch.js'
|
|
5
|
+
|
|
6
|
+
/** A minimal OpenAPI 3.x spec shape. Accepts both hand-written specs and generated ones (e.g. from `@hono/zod-openapi`). */
|
|
7
|
+
export type OpenAPISpec = { paths?: {} | undefined }
|
|
8
|
+
|
|
9
|
+
/** Internal operation shape after casting. */
|
|
10
|
+
type Operation = {
|
|
11
|
+
description?: string | undefined
|
|
12
|
+
operationId?: string | undefined
|
|
13
|
+
parameters?: readonly Parameter[] | undefined
|
|
14
|
+
requestBody?: RequestBody | undefined
|
|
15
|
+
responses?: Record<string, unknown> | undefined
|
|
16
|
+
summary?: string | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Parameter = {
|
|
20
|
+
description?: string | undefined
|
|
21
|
+
in: 'cookie' | 'header' | 'path' | 'query'
|
|
22
|
+
name: string
|
|
23
|
+
required?: boolean | undefined
|
|
24
|
+
schema?: Record<string, unknown> | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type RequestBody = {
|
|
28
|
+
content?: Record<string, { schema?: Record<string, unknown> | undefined }> | undefined
|
|
29
|
+
required?: boolean | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A fetch handler. */
|
|
33
|
+
type FetchHandler = (req: Request) => Response | Promise<Response>
|
|
34
|
+
|
|
35
|
+
/** A generated command entry compatible with incur's internal CommandEntry. */
|
|
36
|
+
type GeneratedCommand = {
|
|
37
|
+
args?: z.ZodObject<any> | undefined
|
|
38
|
+
description?: string | undefined
|
|
39
|
+
options?: z.ZodObject<any> | undefined
|
|
40
|
+
run: (context: any) => any
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Generates incur command entries from an OpenAPI spec. Resolves all `$ref` pointers. */
|
|
44
|
+
export async function generateCommands(
|
|
45
|
+
spec: OpenAPISpec,
|
|
46
|
+
fetch: FetchHandler,
|
|
47
|
+
options: { basePath?: string | undefined } = {},
|
|
48
|
+
): Promise<Map<string, GeneratedCommand>> {
|
|
49
|
+
const resolved = await dereference(structuredClone(spec) as any) as unknown as OpenAPISpec
|
|
50
|
+
const commands = new Map<string, GeneratedCommand>()
|
|
51
|
+
const paths = (resolved.paths ?? {}) as Record<string, Record<string, unknown>>
|
|
52
|
+
|
|
53
|
+
for (const [path, methods] of Object.entries(paths)) {
|
|
54
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
55
|
+
if (method.startsWith('x-')) continue
|
|
56
|
+
const op = operation as Operation
|
|
57
|
+
const name = op.operationId ?? `${method}_${path.replace(/[/{}]/g, '_')}`
|
|
58
|
+
const httpMethod = method.toUpperCase()
|
|
59
|
+
|
|
60
|
+
const pathParams = (op.parameters ?? []).filter((p) => p.in === 'path')
|
|
61
|
+
const queryParams = (op.parameters ?? []).filter((p) => p.in === 'query')
|
|
62
|
+
|
|
63
|
+
const bodySchema = op.requestBody?.content?.['application/json']?.schema
|
|
64
|
+
const bodyProps = (bodySchema?.properties ?? {}) as Record<string, Record<string, unknown>>
|
|
65
|
+
const bodyRequired = new Set((bodySchema?.required as string[]) ?? [])
|
|
66
|
+
|
|
67
|
+
// Build args Zod schema from path params
|
|
68
|
+
let argsSchema: z.ZodObject<any> | undefined
|
|
69
|
+
if (pathParams.length > 0) {
|
|
70
|
+
const shape: Record<string, z.ZodType> = {}
|
|
71
|
+
for (const p of pathParams) {
|
|
72
|
+
let zodType = p.schema ? toZod(p.schema) : z.string()
|
|
73
|
+
if (p.description) zodType = zodType.describe(p.description)
|
|
74
|
+
// Path params need coercion from string argv
|
|
75
|
+
shape[p.name] = coerceIfNeeded(zodType)
|
|
76
|
+
}
|
|
77
|
+
argsSchema = z.object(shape)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build options Zod schema from query params + body properties
|
|
81
|
+
const optShape: Record<string, z.ZodType> = {}
|
|
82
|
+
for (const p of queryParams) {
|
|
83
|
+
let zodType = p.schema ? toZod(p.schema) : z.string()
|
|
84
|
+
if (!p.required) zodType = zodType.optional()
|
|
85
|
+
if (p.description) zodType = zodType.describe(p.description)
|
|
86
|
+
optShape[p.name] = coerceIfNeeded(zodType)
|
|
87
|
+
}
|
|
88
|
+
for (const [key, schema] of Object.entries(bodyProps)) {
|
|
89
|
+
let zodType = toZod(schema)
|
|
90
|
+
if (!bodyRequired.has(key)) zodType = zodType.optional()
|
|
91
|
+
optShape[key] = zodType
|
|
92
|
+
}
|
|
93
|
+
const optionsSchema = Object.keys(optShape).length > 0 ? z.object(optShape) : undefined
|
|
94
|
+
|
|
95
|
+
commands.set(name, {
|
|
96
|
+
description: op.summary ?? op.description,
|
|
97
|
+
args: argsSchema,
|
|
98
|
+
options: optionsSchema,
|
|
99
|
+
run: createHandler({ basePath: options.basePath, fetch, httpMethod, path, pathParams, queryParams, bodyProps }),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return commands
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createHandler(config: {
|
|
108
|
+
basePath?: string | undefined
|
|
109
|
+
bodyProps: Record<string, Record<string, unknown>>
|
|
110
|
+
fetch: FetchHandler
|
|
111
|
+
httpMethod: string
|
|
112
|
+
path: string
|
|
113
|
+
pathParams: Parameter[]
|
|
114
|
+
queryParams: Parameter[]
|
|
115
|
+
}) {
|
|
116
|
+
return async (context: any) => {
|
|
117
|
+
const { args = {}, options = {} } = context
|
|
118
|
+
|
|
119
|
+
// Build URL path with interpolated path params
|
|
120
|
+
let urlPath = (config.basePath ?? '') + config.path
|
|
121
|
+
for (const p of config.pathParams) {
|
|
122
|
+
const value = args[p.name]
|
|
123
|
+
if (value !== undefined) urlPath = urlPath.replace(`{${p.name}}`, String(value))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Build query string from query params
|
|
127
|
+
const query = new URLSearchParams()
|
|
128
|
+
for (const p of config.queryParams) {
|
|
129
|
+
const value = options[p.name]
|
|
130
|
+
if (value !== undefined) query.set(p.name, String(value))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build body from body properties
|
|
134
|
+
let body: string | undefined
|
|
135
|
+
const bodyKeys = Object.keys(config.bodyProps)
|
|
136
|
+
if (bodyKeys.length > 0) {
|
|
137
|
+
const bodyObj: Record<string, unknown> = {}
|
|
138
|
+
for (const key of bodyKeys)
|
|
139
|
+
if (options[key] !== undefined) bodyObj[key] = options[key]
|
|
140
|
+
if (Object.keys(bodyObj).length > 0) body = JSON.stringify(bodyObj)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const input: Fetch.FetchInput = {
|
|
144
|
+
path: urlPath,
|
|
145
|
+
method: config.httpMethod,
|
|
146
|
+
headers: new Headers(),
|
|
147
|
+
body,
|
|
148
|
+
query,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (body) input.headers.set('content-type', 'application/json')
|
|
152
|
+
|
|
153
|
+
const request = Fetch.buildRequest(input)
|
|
154
|
+
const response = await config.fetch(request)
|
|
155
|
+
const output = await Fetch.parseResponse(response)
|
|
156
|
+
|
|
157
|
+
if (!output.ok)
|
|
158
|
+
return context.error({
|
|
159
|
+
code: `HTTP_${output.status}`,
|
|
160
|
+
message:
|
|
161
|
+
typeof output.data === 'object' && output.data !== null && 'message' in output.data
|
|
162
|
+
? String((output.data as any).message)
|
|
163
|
+
: typeof output.data === 'string'
|
|
164
|
+
? output.data
|
|
165
|
+
: `HTTP ${output.status}`,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
return output.data
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Converts a JSON Schema object to a Zod schema. */
|
|
173
|
+
function toZod(schema: Record<string, unknown>): z.ZodType {
|
|
174
|
+
return z.fromJSONSchema(schema)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Wraps a Zod schema with coercion if the base type is number or boolean (argv is always strings). */
|
|
178
|
+
function coerceIfNeeded(schema: z.ZodType): z.ZodType {
|
|
179
|
+
const isOptional = schema instanceof z.ZodOptional
|
|
180
|
+
const inner = isOptional ? schema.unwrap() : schema
|
|
181
|
+
|
|
182
|
+
// Direct number/boolean
|
|
183
|
+
if (inner instanceof z.ZodNumber) return isOptional ? z.coerce.number().optional() : z.coerce.number()
|
|
184
|
+
if (inner instanceof z.ZodBoolean) return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean()
|
|
185
|
+
|
|
186
|
+
// Union containing number (e.g. type: ["number", "null"] from OpenAPI 3.1)
|
|
187
|
+
if (inner instanceof z.ZodUnion) {
|
|
188
|
+
const options = (inner as any)._zod?.def?.options as z.ZodType[] | undefined
|
|
189
|
+
if (options?.some((o: z.ZodType) => o instanceof z.ZodNumber))
|
|
190
|
+
return isOptional ? z.coerce.number().optional() : z.coerce.number()
|
|
191
|
+
if (options?.some((o: z.ZodType) => o instanceof z.ZodBoolean))
|
|
192
|
+
return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return schema
|
|
196
|
+
}
|