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.
@@ -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
+ }