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,274 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { app } from '../test/fixtures/hono-api.js'
4
+ import * as Fetch from './Fetch.js'
5
+
6
+ describe('parseArgv', () => {
7
+ test('bare tokens → path segments', () => {
8
+ const input = Fetch.parseArgv(['users', 'list'])
9
+ expect(input.path).toBe('/users/list')
10
+ })
11
+
12
+ test('empty argv → root path', () => {
13
+ const input = Fetch.parseArgv([])
14
+ expect(input.path).toBe('/')
15
+ })
16
+
17
+ test('single token', () => {
18
+ const input = Fetch.parseArgv(['health'])
19
+ expect(input.path).toBe('/health')
20
+ })
21
+
22
+ test('default method is GET', () => {
23
+ const input = Fetch.parseArgv(['users'])
24
+ expect(input.method).toBe('GET')
25
+ })
26
+
27
+ test('-X sets method', () => {
28
+ const input = Fetch.parseArgv(['users', '-X', 'POST'])
29
+ expect(input.method).toBe('POST')
30
+ })
31
+
32
+ test('--method sets method', () => {
33
+ const input = Fetch.parseArgv(['users', '--method', 'DELETE'])
34
+ expect(input.method).toBe('DELETE')
35
+ })
36
+
37
+ test('default POST when body present', () => {
38
+ const input = Fetch.parseArgv(['users', '--body', '{"name":"Eve"}'])
39
+ expect(input.method).toBe('POST')
40
+ })
41
+
42
+ test('-d sets body', () => {
43
+ const input = Fetch.parseArgv(['users', '-d', '{"name":"Bob"}'])
44
+ expect(input.body).toBe('{"name":"Bob"}')
45
+ expect(input.method).toBe('POST')
46
+ })
47
+
48
+ test('--data sets body', () => {
49
+ const input = Fetch.parseArgv(['users', '--data', '{"x":1}'])
50
+ expect(input.body).toBe('{"x":1}')
51
+ })
52
+
53
+ test('explicit method overrides body default', () => {
54
+ const input = Fetch.parseArgv(['users', '-X', 'PUT', '-d', '{"name":"Bob"}'])
55
+ expect(input.method).toBe('PUT')
56
+ })
57
+
58
+ test('-H sets header', () => {
59
+ const input = Fetch.parseArgv(['users', '-H', 'X-Api-Key: secret'])
60
+ expect(input.headers.get('X-Api-Key')).toBe('secret')
61
+ })
62
+
63
+ test('--header sets header', () => {
64
+ const input = Fetch.parseArgv(['users', '--header', 'Authorization: Bearer tok'])
65
+ expect(input.headers.get('Authorization')).toBe('Bearer tok')
66
+ })
67
+
68
+ test('multiple headers', () => {
69
+ const input = Fetch.parseArgv([
70
+ 'users',
71
+ '-H',
72
+ 'X-A: 1',
73
+ '-H',
74
+ 'X-B: 2',
75
+ ])
76
+ expect(input.headers.get('X-A')).toBe('1')
77
+ expect(input.headers.get('X-B')).toBe('2')
78
+ })
79
+
80
+ test('unknown --key value → query params', () => {
81
+ const input = Fetch.parseArgv(['users', '--limit', '5', '--sort', 'name'])
82
+ expect(input.query.get('limit')).toBe('5')
83
+ expect(input.query.get('sort')).toBe('name')
84
+ })
85
+
86
+ test('--key=value → query params', () => {
87
+ const input = Fetch.parseArgv(['users', '--limit=5'])
88
+ expect(input.query.get('limit')).toBe('5')
89
+ })
90
+
91
+ test('mixed tokens, flags, and query params', () => {
92
+ const input = Fetch.parseArgv([
93
+ 'users',
94
+ '42',
95
+ '--limit',
96
+ '5',
97
+ '-X',
98
+ 'POST',
99
+ '-d',
100
+ '{"x":1}',
101
+ '-H',
102
+ 'Auth: tok',
103
+ ])
104
+ expect(input.path).toBe('/users/42')
105
+ expect(input.method).toBe('POST')
106
+ expect(input.body).toBe('{"x":1}')
107
+ expect(input.query.get('limit')).toBe('5')
108
+ expect(input.headers.get('Auth')).toBe('tok')
109
+ })
110
+ })
111
+
112
+ describe('buildRequest', () => {
113
+ test('builds GET request with path', () => {
114
+ const req = Fetch.buildRequest(Fetch.parseArgv(['users']))
115
+ expect(req.method).toBe('GET')
116
+ expect(new URL(req.url).pathname).toBe('/users')
117
+ })
118
+
119
+ test('builds request with query params', () => {
120
+ const req = Fetch.buildRequest(Fetch.parseArgv(['users', '--limit', '5']))
121
+ const url = new URL(req.url)
122
+ expect(url.searchParams.get('limit')).toBe('5')
123
+ })
124
+
125
+ test('builds POST request with body', () => {
126
+ const req = Fetch.buildRequest(
127
+ Fetch.parseArgv(['users', '-X', 'POST', '-d', '{"name":"Bob"}']),
128
+ )
129
+ expect(req.method).toBe('POST')
130
+ })
131
+
132
+ test('builds request with headers', () => {
133
+ const req = Fetch.buildRequest(
134
+ Fetch.parseArgv(['users', '-H', 'X-Api-Key: secret']),
135
+ )
136
+ expect(req.headers.get('X-Api-Key')).toBe('secret')
137
+ })
138
+ })
139
+
140
+ describe('parseResponse', () => {
141
+ test('parses JSON response', async () => {
142
+ const res = new Response(JSON.stringify({ ok: true }), {
143
+ status: 200,
144
+ headers: { 'content-type': 'application/json' },
145
+ })
146
+ const output = await Fetch.parseResponse(res)
147
+ expect(output.ok).toBe(true)
148
+ expect(output.status).toBe(200)
149
+ expect(output.data).toEqual({ ok: true })
150
+ })
151
+
152
+ test('parses text response', async () => {
153
+ const res = new Response('hello world', { status: 200 })
154
+ const output = await Fetch.parseResponse(res)
155
+ expect(output.data).toBe('hello world')
156
+ })
157
+
158
+ test('error status → ok: false', async () => {
159
+ const res = new Response(JSON.stringify({ message: 'not found' }), {
160
+ status: 404,
161
+ headers: { 'content-type': 'application/json' },
162
+ })
163
+ const output = await Fetch.parseResponse(res)
164
+ expect(output.ok).toBe(false)
165
+ expect(output.status).toBe(404)
166
+ expect(output.data).toEqual({ message: 'not found' })
167
+ })
168
+ })
169
+
170
+ describe('round-trip with Hono', () => {
171
+ test('GET /users', async () => {
172
+ const input = Fetch.parseArgv(['users'])
173
+ const req = Fetch.buildRequest(input)
174
+ const res = await app.fetch(req)
175
+ const output = await Fetch.parseResponse(res)
176
+ expect(output.ok).toBe(true)
177
+ expect(output.data).toEqual({ users: [{ id: 1, name: 'Alice' }], limit: 10 })
178
+ })
179
+
180
+ test('GET /users?limit=5', async () => {
181
+ const input = Fetch.parseArgv(['users', '--limit', '5'])
182
+ const req = Fetch.buildRequest(input)
183
+ const res = await app.fetch(req)
184
+ const output = await Fetch.parseResponse(res)
185
+ expect(output.ok).toBe(true)
186
+ expect(output.data).toEqual({ users: [{ id: 1, name: 'Alice' }], limit: 5 })
187
+ })
188
+
189
+ test('GET /users/:id', async () => {
190
+ const input = Fetch.parseArgv(['users', '42'])
191
+ const req = Fetch.buildRequest(input)
192
+ const res = await app.fetch(req)
193
+ const output = await Fetch.parseResponse(res)
194
+ expect(output.ok).toBe(true)
195
+ expect(output.data).toEqual({ id: 42, name: 'Alice' })
196
+ })
197
+
198
+ test('POST /users with body', async () => {
199
+ const input = Fetch.parseArgv(['users', '-X', 'POST', '-d', '{"name":"Bob"}'])
200
+ const req = Fetch.buildRequest(input)
201
+ const res = await app.fetch(req)
202
+ const output = await Fetch.parseResponse(res)
203
+ expect(output.ok).toBe(true)
204
+ expect(output.status).toBe(201)
205
+ expect(output.data).toEqual({ created: true, name: 'Bob' })
206
+ })
207
+
208
+ test('POST /users with implicit method', async () => {
209
+ const input = Fetch.parseArgv(['users', '--body', '{"name":"Eve"}'])
210
+ const req = Fetch.buildRequest(input)
211
+ const res = await app.fetch(req)
212
+ const output = await Fetch.parseResponse(res)
213
+ expect(output.ok).toBe(true)
214
+ expect(output.data).toEqual({ created: true, name: 'Eve' })
215
+ })
216
+
217
+ test('DELETE /users/:id', async () => {
218
+ const input = Fetch.parseArgv(['users', '1', '--method', 'DELETE'])
219
+ const req = Fetch.buildRequest(input)
220
+ const res = await app.fetch(req)
221
+ const output = await Fetch.parseResponse(res)
222
+ expect(output.ok).toBe(true)
223
+ expect(output.data).toEqual({ deleted: true, id: 1 })
224
+ })
225
+
226
+ test('GET /health', async () => {
227
+ const input = Fetch.parseArgv(['health'])
228
+ const req = Fetch.buildRequest(input)
229
+ const res = await app.fetch(req)
230
+ const output = await Fetch.parseResponse(res)
231
+ expect(output.ok).toBe(true)
232
+ expect(output.data).toEqual({ ok: true })
233
+ })
234
+
235
+ test('GET /error → 404', async () => {
236
+ const input = Fetch.parseArgv(['error'])
237
+ const req = Fetch.buildRequest(input)
238
+ const res = await app.fetch(req)
239
+ const output = await Fetch.parseResponse(res)
240
+ expect(output.ok).toBe(false)
241
+ expect(output.status).toBe(404)
242
+ expect(output.data).toEqual({ message: 'not found' })
243
+ })
244
+
245
+ test('GET /text → plain text', async () => {
246
+ const input = Fetch.parseArgv(['text'])
247
+ const req = Fetch.buildRequest(input)
248
+ const res = await app.fetch(req)
249
+ const output = await Fetch.parseResponse(res)
250
+ expect(output.ok).toBe(true)
251
+ expect(output.data).toBe('hello world')
252
+ })
253
+
254
+ test('custom headers pass through', async () => {
255
+ const input = Fetch.parseArgv(['users', '-H', 'X-Custom: hello'])
256
+ const req = Fetch.buildRequest(input)
257
+ expect(req.headers.get('X-Custom')).toBe('hello')
258
+ const res = await app.fetch(req)
259
+ const output = await Fetch.parseResponse(res)
260
+ expect(output.ok).toBe(true)
261
+ })
262
+
263
+ test('streaming NDJSON response', async () => {
264
+ const input = Fetch.parseArgv(['stream'])
265
+ const req = Fetch.buildRequest(input)
266
+ const res = await app.fetch(req)
267
+ expect(Fetch.isStreamingResponse(res)).toBe(true)
268
+ const chunks: unknown[] = []
269
+ for await (const chunk of Fetch.parseStreamingResponse(res)) {
270
+ chunks.push(chunk)
271
+ }
272
+ expect(chunks).toEqual([{ progress: 1 }, { progress: 2 }])
273
+ })
274
+ })
package/src/Fetch.ts ADDED
@@ -0,0 +1,170 @@
1
+ /** Structured input parsed from curl-style argv. */
2
+ export type FetchInput = {
3
+ body: string | undefined
4
+ headers: Headers
5
+ method: string
6
+ path: string
7
+ query: URLSearchParams
8
+ }
9
+
10
+ /** Structured output from a fetch Response. */
11
+ export type FetchOutput = {
12
+ data: unknown
13
+ headers: Headers
14
+ ok: boolean
15
+ status: number
16
+ }
17
+
18
+ /** Reserved flags consumed by the fetch gateway (not forwarded as query params). */
19
+ const reservedFlags = new Set(['method', 'body', 'data', 'header'])
20
+ const reservedShort: Record<string, string> = { X: 'method', d: 'data', H: 'header' }
21
+
22
+ /** Parses curl-style argv into a structured fetch input. */
23
+ export function parseArgv(argv: string[]): FetchInput {
24
+ const segments: string[] = []
25
+ const headers = new Headers()
26
+ const query = new URLSearchParams()
27
+ let method: string | undefined
28
+ let body: string | undefined
29
+
30
+ let i = 0
31
+ while (i < argv.length) {
32
+ const token = argv[i]!
33
+
34
+ if (token.startsWith('--')) {
35
+ const eqIdx = token.indexOf('=')
36
+ if (eqIdx !== -1) {
37
+ // --key=value
38
+ const key = token.slice(2, eqIdx)
39
+ const value = token.slice(eqIdx + 1)
40
+ if (reservedFlags.has(key)) handleReserved(key, value)
41
+ else query.set(key, value)
42
+ i++
43
+ } else {
44
+ const key = token.slice(2)
45
+ const value = argv[i + 1]
46
+ if (reservedFlags.has(key)) {
47
+ handleReserved(key, value!)
48
+ i += 2
49
+ } else {
50
+ query.set(key, value!)
51
+ i += 2
52
+ }
53
+ }
54
+ } else if (token.startsWith('-') && token.length === 2) {
55
+ const short = token[1]!
56
+ const mapped = reservedShort[short]
57
+ const value = argv[i + 1]!
58
+ if (mapped) {
59
+ handleReserved(mapped, value)
60
+ i += 2
61
+ } else {
62
+ // Unknown short flag — skip (shouldn't happen in fetch context)
63
+ i += 2
64
+ }
65
+ } else {
66
+ segments.push(token)
67
+ i++
68
+ }
69
+ }
70
+
71
+ function handleReserved(key: string, value: string) {
72
+ if (key === 'method') method = value.toUpperCase()
73
+ else if (key === 'body' || key === 'data') body = value
74
+ else if (key === 'header') {
75
+ const colonIdx = value.indexOf(':')
76
+ if (colonIdx !== -1) {
77
+ const name = value.slice(0, colonIdx).trim()
78
+ const val = value.slice(colonIdx + 1).trim()
79
+ headers.set(name, val)
80
+ }
81
+ }
82
+ }
83
+
84
+ return {
85
+ path: segments.length > 0 ? `/${segments.join('/')}` : '/',
86
+ method: method ?? (body !== undefined ? 'POST' : 'GET'),
87
+ headers,
88
+ body,
89
+ query,
90
+ }
91
+ }
92
+
93
+ /** Constructs a standard Request from a FetchInput. */
94
+ export function buildRequest(input: FetchInput): Request {
95
+ const url = new URL(input.path, 'http://localhost')
96
+ input.query.forEach((value, key) => url.searchParams.set(key, value))
97
+
98
+ const init: RequestInit = {
99
+ method: input.method,
100
+ headers: input.headers,
101
+ }
102
+
103
+ if (input.body !== undefined) {
104
+ init.body = input.body
105
+ if (!input.headers.has('content-type'))
106
+ input.headers.set('content-type', 'application/json')
107
+ }
108
+
109
+ return new Request(url.toString(), init)
110
+ }
111
+
112
+ /** Returns true if the response body is a stream that should be consumed incrementally. */
113
+ export function isStreamingResponse(response: Response): boolean {
114
+ return response.body !== null && response.headers.get('content-type') === 'application/x-ndjson'
115
+ }
116
+
117
+ /** Parses a streaming response body as an async generator of parsed NDJSON chunks. */
118
+ export async function* parseStreamingResponse(
119
+ response: Response,
120
+ ): AsyncGenerator<unknown, void, unknown> {
121
+ const reader = response.body!.getReader()
122
+ const decoder = new TextDecoder()
123
+ let buffer = ''
124
+
125
+ while (true) {
126
+ const { value, done } = await reader.read()
127
+ if (done) break
128
+ buffer += decoder.decode(value, { stream: true })
129
+
130
+ let newlineIdx: number
131
+ while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
132
+ const line = buffer.slice(0, newlineIdx).trim()
133
+ buffer = buffer.slice(newlineIdx + 1)
134
+ if (line.length === 0) continue
135
+ try {
136
+ yield JSON.parse(line)
137
+ } catch {
138
+ yield line
139
+ }
140
+ }
141
+ }
142
+
143
+ // flush remaining buffer
144
+ const remaining = buffer.trim()
145
+ if (remaining.length > 0) {
146
+ try {
147
+ yield JSON.parse(remaining)
148
+ } catch {
149
+ yield remaining
150
+ }
151
+ }
152
+ }
153
+
154
+ /** Parses a fetch Response into structured output. */
155
+ export async function parseResponse(response: Response): Promise<FetchOutput> {
156
+ const text = await response.text()
157
+ let data: unknown
158
+ try {
159
+ data = JSON.parse(text)
160
+ } catch {
161
+ data = text
162
+ }
163
+
164
+ return {
165
+ ok: response.ok,
166
+ status: response.status,
167
+ data,
168
+ headers: response.headers,
169
+ }
170
+ }