@c9up/ream 0.1.26 → 0.1.27

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.
Files changed (179) hide show
  1. package/README.md +65 -0
  2. package/dist/Ignitor.d.ts +115 -7
  3. package/dist/Ignitor.d.ts.map +1 -1
  4. package/dist/Ignitor.js +233 -37
  5. package/dist/Ignitor.js.map +1 -1
  6. package/dist/console/BaseCommand.d.ts +217 -0
  7. package/dist/console/BaseCommand.d.ts.map +1 -0
  8. package/dist/console/BaseCommand.js +340 -0
  9. package/dist/console/BaseCommand.js.map +1 -0
  10. package/dist/console/Console.d.ts +86 -0
  11. package/dist/console/Console.d.ts.map +1 -0
  12. package/dist/console/Console.js +134 -0
  13. package/dist/console/Console.js.map +1 -0
  14. package/dist/console/ExceptionHandler.d.ts +28 -0
  15. package/dist/console/ExceptionHandler.d.ts.map +1 -0
  16. package/dist/console/ExceptionHandler.js +76 -0
  17. package/dist/console/ExceptionHandler.js.map +1 -0
  18. package/dist/console/HelpCommand.d.ts +16 -0
  19. package/dist/console/HelpCommand.d.ts.map +1 -0
  20. package/dist/console/HelpCommand.js +40 -0
  21. package/dist/console/HelpCommand.js.map +1 -0
  22. package/dist/console/IndexGenerator.d.ts +24 -0
  23. package/dist/console/IndexGenerator.d.ts.map +1 -0
  24. package/dist/console/IndexGenerator.js +65 -0
  25. package/dist/console/IndexGenerator.js.map +1 -0
  26. package/dist/console/Kernel.d.ts +298 -0
  27. package/dist/console/Kernel.d.ts.map +1 -0
  28. package/dist/console/Kernel.js +1120 -0
  29. package/dist/console/Kernel.js.map +1 -0
  30. package/dist/console/ListCommand.d.ts +18 -0
  31. package/dist/console/ListCommand.d.ts.map +1 -0
  32. package/dist/console/ListCommand.js +60 -0
  33. package/dist/console/ListCommand.js.map +1 -0
  34. package/dist/console/assertions.d.ts +20 -0
  35. package/dist/console/assertions.d.ts.map +1 -0
  36. package/dist/console/assertions.js +84 -0
  37. package/dist/console/assertions.js.map +1 -0
  38. package/dist/console/cliui.d.ts +248 -0
  39. package/dist/console/cliui.d.ts.map +1 -0
  40. package/dist/console/cliui.js +587 -0
  41. package/dist/console/cliui.js.map +1 -0
  42. package/dist/console/decorators.d.ts +90 -0
  43. package/dist/console/decorators.d.ts.map +1 -0
  44. package/dist/console/decorators.js +142 -0
  45. package/dist/console/decorators.js.map +1 -0
  46. package/dist/console/index.d.ts +32 -0
  47. package/dist/console/index.d.ts.map +1 -0
  48. package/dist/console/index.js +24 -0
  49. package/dist/console/index.js.map +1 -0
  50. package/dist/console/loaders.d.ts +52 -0
  51. package/dist/console/loaders.d.ts.map +1 -0
  52. package/dist/console/loaders.js +131 -0
  53. package/dist/console/loaders.js.map +1 -0
  54. package/dist/console/parser.d.ts +86 -0
  55. package/dist/console/parser.d.ts.map +1 -0
  56. package/dist/console/parser.js +440 -0
  57. package/dist/console/parser.js.map +1 -0
  58. package/dist/console/prompts.d.ts +124 -0
  59. package/dist/console/prompts.d.ts.map +1 -0
  60. package/dist/console/prompts.js +356 -0
  61. package/dist/console/prompts.js.map +1 -0
  62. package/dist/console/tracing.d.ts +17 -0
  63. package/dist/console/tracing.d.ts.map +1 -0
  64. package/dist/console/tracing.js +11 -0
  65. package/dist/console/tracing.js.map +1 -0
  66. package/dist/console/types.d.ts +255 -0
  67. package/dist/console/types.d.ts.map +1 -0
  68. package/dist/console/types.js +25 -0
  69. package/dist/console/types.js.map +1 -0
  70. package/dist/console/ui.d.ts +20 -0
  71. package/dist/console/ui.d.ts.map +1 -0
  72. package/dist/console/ui.js +31 -0
  73. package/dist/console/ui.js.map +1 -0
  74. package/dist/console/utils.d.ts +27 -0
  75. package/dist/console/utils.d.ts.map +1 -0
  76. package/dist/console/utils.js +56 -0
  77. package/dist/console/utils.js.map +1 -0
  78. package/dist/container/Container.d.ts.map +1 -1
  79. package/dist/container/Container.js +40 -12
  80. package/dist/container/Container.js.map +1 -1
  81. package/dist/container/types.d.ts +14 -2
  82. package/dist/container/types.d.ts.map +1 -1
  83. package/dist/decorators/Service.d.ts +7 -0
  84. package/dist/decorators/Service.d.ts.map +1 -1
  85. package/dist/decorators/Service.js +23 -3
  86. package/dist/decorators/Service.js.map +1 -1
  87. package/dist/errors/ReamError.d.ts +25 -0
  88. package/dist/errors/ReamError.d.ts.map +1 -1
  89. package/dist/errors/ReamError.js +5 -0
  90. package/dist/errors/ReamError.js.map +1 -1
  91. package/dist/errors/prettyPrintError.d.ts +9 -0
  92. package/dist/errors/prettyPrintError.d.ts.map +1 -0
  93. package/dist/errors/prettyPrintError.js +22 -0
  94. package/dist/errors/prettyPrintError.js.map +1 -0
  95. package/dist/index.d.ts +14 -4
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +16 -2
  98. package/dist/index.js.map +1 -1
  99. package/dist/middleware/Pipeline.d.ts +29 -8
  100. package/dist/middleware/Pipeline.d.ts.map +1 -1
  101. package/dist/middleware/Pipeline.js +39 -1
  102. package/dist/middleware/Pipeline.js.map +1 -1
  103. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  104. package/dist/rpc/RpcRouter.js +13 -2
  105. package/dist/rpc/RpcRouter.js.map +1 -1
  106. package/dist/services/app.d.ts +1 -1
  107. package/dist/services/app.js +1 -1
  108. package/dist/services/console.d.ts +21 -0
  109. package/dist/services/console.d.ts.map +1 -0
  110. package/dist/services/console.js +35 -0
  111. package/dist/services/console.js.map +1 -0
  112. package/dist/testing/ApiResponse.d.ts +218 -0
  113. package/dist/testing/ApiResponse.d.ts.map +1 -0
  114. package/dist/testing/ApiResponse.js +576 -0
  115. package/dist/testing/ApiResponse.js.map +1 -0
  116. package/dist/testing/RequestBuilder.d.ts +132 -72
  117. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  118. package/dist/testing/RequestBuilder.js +389 -222
  119. package/dist/testing/RequestBuilder.js.map +1 -1
  120. package/dist/testing/TestClient.d.ts +34 -39
  121. package/dist/testing/TestClient.d.ts.map +1 -1
  122. package/dist/testing/TestClient.js +48 -56
  123. package/dist/testing/TestClient.js.map +1 -1
  124. package/dist/utils/Macroable.js +3 -3
  125. package/dist/utils/Macroable.js.map +1 -1
  126. package/events.darwin-arm64.node +0 -0
  127. package/events.darwin-x64.node +0 -0
  128. package/events.linux-arm64-gnu.node +0 -0
  129. package/events.linux-x64-gnu.node +0 -0
  130. package/events.win32-x64-msvc.node +0 -0
  131. package/index.darwin-arm64.node +0 -0
  132. package/index.darwin-x64.node +0 -0
  133. package/index.linux-arm64-gnu.node +0 -0
  134. package/index.linux-x64-gnu.node +0 -0
  135. package/index.win32-x64-msvc.node +0 -0
  136. package/package.json +15 -2
  137. package/scheduler.darwin-arm64.node +0 -0
  138. package/scheduler.darwin-x64.node +0 -0
  139. package/scheduler.linux-arm64-gnu.node +0 -0
  140. package/scheduler.linux-x64-gnu.node +0 -0
  141. package/scheduler.win32-x64-msvc.node +0 -0
  142. package/src/Ignitor.ts +340 -42
  143. package/src/console/BaseCommand.ts +467 -0
  144. package/src/console/Console.ts +174 -0
  145. package/src/console/ExceptionHandler.ts +91 -0
  146. package/src/console/HelpCommand.ts +37 -0
  147. package/src/console/IndexGenerator.ts +79 -0
  148. package/src/console/Kernel.ts +1382 -0
  149. package/src/console/ListCommand.ts +55 -0
  150. package/src/console/assertions.ts +114 -0
  151. package/src/console/cliui.ts +756 -0
  152. package/src/console/decorators.ts +199 -0
  153. package/src/console/index.ts +67 -0
  154. package/src/console/loaders.ts +145 -0
  155. package/src/console/parser.ts +598 -0
  156. package/src/console/prompts.ts +548 -0
  157. package/src/console/tracing.ts +22 -0
  158. package/src/console/types.ts +280 -0
  159. package/src/console/ui.ts +37 -0
  160. package/src/console/utils.ts +69 -0
  161. package/src/container/Container.ts +55 -13
  162. package/src/container/types.ts +15 -2
  163. package/src/decorators/Service.ts +28 -3
  164. package/src/errors/ReamError.ts +31 -0
  165. package/src/errors/prettyPrintError.ts +20 -0
  166. package/src/index.ts +30 -4
  167. package/src/middleware/Pipeline.ts +76 -5
  168. package/src/rpc/RpcRouter.ts +16 -2
  169. package/src/services/app.ts +1 -1
  170. package/src/services/console.ts +42 -0
  171. package/src/testing/ApiResponse.ts +691 -0
  172. package/src/testing/RequestBuilder.ts +498 -275
  173. package/src/testing/TestClient.ts +63 -86
  174. package/src/utils/Macroable.ts +3 -3
  175. package/dist/console/CommandRunner.d.ts +0 -18
  176. package/dist/console/CommandRunner.d.ts.map +0 -1
  177. package/dist/console/CommandRunner.js +0 -65
  178. package/dist/console/CommandRunner.js.map +0 -1
  179. package/src/console/CommandRunner.ts +0 -82
@@ -1,19 +1,44 @@
1
- import type { TestResponse } from './TestClient.js'
2
-
3
1
  /**
4
- * Fluent HTTP request builder with assertion methods.
2
+ * Fluent HTTP request builder with assertion methods (`@japa/api-client` model).
5
3
  *
6
4
  * await client
7
- * .fluent('GET', '/api/users/42')
5
+ * .get('/api/users/42')
8
6
  * .withAuth(user)
9
- * .expectStatus(200)
10
- * .expectJson({ id: 42 })
7
+ * .assertOk()
8
+ * .assertBody({ id: 42 })
11
9
  *
12
- * The builder chains: setters return `this`, assertion methods send the
13
- * request on first call (memoised) and return a `Promise<this>` so further
14
- * assertions can be awaited or chained via `await`.
10
+ * The builder chains SYNCHRONOUSLY: setters and assertion methods both return
11
+ * `this`. Assertions are LAZY — each registers a check; the request is sent once
12
+ * and every check runs, in order, when the builder is awaited (it is thenable,
13
+ * so `await builder` resolves to the {@link ApiResponse}). A plain
14
+ * `await client.get('/x')` (no assertion) just sends and returns the response.
15
15
  */
16
16
 
17
+ import { readFileSync } from 'node:fs'
18
+ import { basename } from 'node:path'
19
+ import type { Readable } from 'node:stream'
20
+ import {
21
+ ApiResponse,
22
+ capBody,
23
+ ExpectationError,
24
+ partialMatch,
25
+ STATUS_ASSERTIONS,
26
+ type StatusAssertions,
27
+ type TestResponse,
28
+ } from './ApiResponse.js'
29
+
30
+ // Re-export the shared response types + matchers so `@c9up/ream/testing`
31
+ // consumers keep importing them from here (back-compat).
32
+ export {
33
+ ApiResponse,
34
+ deepEqual,
35
+ partialMatch,
36
+ type ResponseCookie,
37
+ type ResponseError,
38
+ type ResponseFile,
39
+ type TestResponse,
40
+ } from './ApiResponse.js'
41
+
17
42
  export interface AuthSubject {
18
43
  /** String or numeric user id — used by `withAuth` / `asUser` to sign the session. */
19
44
  id: string | number
@@ -28,26 +53,53 @@ export interface AuthStrategy {
28
53
  cookiesFor?(subject: AuthSubject): Record<string, string> | Promise<Record<string, string>>
29
54
  }
30
55
 
31
- /** Assertion failure — plain Error so tests see a clean stack trace. */
32
- class ExpectationError extends Error {
33
- constructor(message: string) {
34
- super(message)
35
- this.name = 'ExpectationError'
36
- }
37
- }
38
-
39
- export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
56
+ /**
57
+ * HTTP methods. The common verbs are named; `request(url, method)` also accepts
58
+ * any other method (e.g. `TRACE`, `CONNECT`) — Japa parity — via the wider
59
+ * string fallback.
60
+ */
61
+ export type HttpMethod =
62
+ | 'GET'
63
+ | 'HEAD'
64
+ | 'POST'
65
+ | 'PUT'
66
+ | 'PATCH'
67
+ | 'DELETE'
68
+ | 'OPTIONS'
69
+ | 'TRACE'
70
+ | 'CONNECT'
71
+ | (string & {})
40
72
 
41
73
  /** Cookie the signed CSRF token is issued in (blackhole default: `XSRF-TOKEN`). */
42
74
  const CSRF_COOKIE_NAME = 'XSRF-TOKEN'
43
75
  /** Header the client echoes the token back in (Axios/Angular default). */
44
76
  const CSRF_HEADER_NAME = 'x-xsrf-token'
45
77
 
78
+ /** Content-type / accept shorthands — mirrors japa/api-client's `.type()`/`.accept()`. */
79
+ const TYPE_SHORTHANDS: Record<string, string> = {
80
+ json: 'application/json',
81
+ form: 'application/x-www-form-urlencoded',
82
+ urlencoded: 'application/x-www-form-urlencoded',
83
+ text: 'text/plain',
84
+ html: 'text/html',
85
+ xml: 'application/xml',
86
+ }
87
+
88
+ /** Expand a shorthand (`'json'`) to a full MIME type, or pass a full type through. */
89
+ function resolveMime(type: string): string {
90
+ return TYPE_SHORTHANDS[type] ?? type
91
+ }
92
+
46
93
  /** Primitive accepted as a query-string value (mirrors `.qs()` in japa/api-client). */
47
94
  type QueryValue = string | number | boolean
48
95
  /** Query-string map — a value or an array of values (repeated key). */
49
96
  export type QueryParams = Record<string, QueryValue | ReadonlyArray<QueryValue>>
50
97
 
98
+ /** A scalar accepted by `form()`/`fields()` — Japa parity (arrays repeat the key). */
99
+ export type FieldValue = string | number | boolean | Buffer
100
+ /** A value accepted by a multipart `field()`/`file()` — Japa parity (incl. streams/blobs). */
101
+ export type MultipartValue = string | number | boolean | Buffer | Blob | Readable
102
+
51
103
  /**
52
104
  * Internal low-level sender — matches what the TestClient exposes.
53
105
  * Accepting it as an injected callable keeps RequestBuilder framework-agnostic.
@@ -58,22 +110,42 @@ export type HttpSender = (
58
110
  init: {
59
111
  headers: Record<string, string>
60
112
  body: Buffer
113
+ /** Per-request socket timeout (ms) — Japa `.timeout()`. Sender may honour it. */
114
+ timeoutMs?: number
61
115
  },
62
116
  ) => Promise<TestResponse>
63
117
 
64
118
  /** Options for a multipart file part — mirrors Adonis/japa's `.file()` options. */
65
119
  export interface FilePart {
66
- /** Filename advertised in the part's `Content-Disposition`. Defaults to the field name. */
120
+ /** Filename advertised in the part's `Content-Disposition`. Defaults to the field name / basename. */
67
121
  filename?: string
68
122
  /** MIME type of the part. Defaults to `application/octet-stream`. */
69
123
  contentType?: string
70
124
  }
71
125
 
72
- /** One encoded multipart/form-data part — a text field or an uploaded file. */
126
+ /**
127
+ * One multipart/form-data part — a field or an uploaded file. The value/content
128
+ * may be a stream/blob; it is resolved to a Buffer at send time (`#execute`).
129
+ */
73
130
  type MultipartPart =
74
- | { kind: 'field'; name: string; value: string }
75
- | { kind: 'file'; name: string; filename: string; contentType: string; content: Buffer }
76
-
131
+ | { kind: 'field'; name: string; value: MultipartValue }
132
+ | { kind: 'file'; name: string; filename: string; contentType: string; content: MultipartValue }
133
+
134
+ /** HTTP statuses that trigger redirect-following (Japa `.redirects()`). */
135
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
136
+ /** Japa follows 5 redirects by default. */
137
+ const DEFAULT_MAX_REDIRECTS = 5
138
+
139
+ /** Consumer-registered macros/getters (Japa `ApiRequest.macro`/`.getter`). */
140
+ const requestMacros = new Map<string, unknown>()
141
+ const requestGetters = new Map<string, (this: RequestBuilder, req: RequestBuilder) => unknown>()
142
+ /** Consumer-registered request body serializers (Japa `ApiRequest.addSerializer`), by name. */
143
+ const requestSerializers = new Map<
144
+ string,
145
+ (data: unknown) => { body: Buffer; contentType: string }
146
+ >()
147
+
148
+ // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the merged interface (StatusAssertions) types the status-shortcut asserts attached from STATUS_ASSERTIONS at load — the same generated-method pattern as EonSchema/Macroable (AdonisJS parity); every member is implemented, so the merge is safe.
77
149
  export class RequestBuilder {
78
150
  #sender: HttpSender
79
151
  #method: HttpMethod
@@ -85,7 +157,13 @@ export class RequestBuilder {
85
157
  #query = new URLSearchParams()
86
158
  #authStrategy: AuthStrategy | null
87
159
  #pendingAuth: AuthSubject | null = null
88
- #sent: Promise<TestResponse> | null = null
160
+ #timeoutMs: number | undefined
161
+ #maxRedirects = DEFAULT_MAX_REDIRECTS
162
+ #sent: Promise<ApiResponse> | null = null
163
+ // Lazy assertions (japa model): each `assert*`/`expect*` registers a check and
164
+ // returns `this` synchronously; the checks run in order when the builder is
165
+ // awaited (`then`) — after the single send.
166
+ #checks: Array<(res: ApiResponse) => void> = []
89
167
 
90
168
  constructor(
91
169
  sender: HttpSender,
@@ -97,6 +175,7 @@ export class RequestBuilder {
97
175
  this.#method = method
98
176
  this.#path = path
99
177
  this.#authStrategy = authStrategy
178
+ applyRequestExtensions(this)
100
179
  }
101
180
 
102
181
  headers(map: Record<string, string>): this {
@@ -106,8 +185,9 @@ export class RequestBuilder {
106
185
  return this
107
186
  }
108
187
 
109
- header(name: string, value: string): this {
110
- this.#headers[name.toLowerCase()] = value
188
+ header(name: string, value: string | string[]): this {
189
+ // Japa accepts string | string[]; a list is joined into one header value.
190
+ this.#headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value
111
191
  return this
112
192
  }
113
193
 
@@ -117,45 +197,80 @@ export class RequestBuilder {
117
197
  return this
118
198
  }
119
199
 
200
+ /** Japa `.unsafeJson()` — set a JSON body without any transform (alias of {@link json}). */
201
+ unsafeJson(data: unknown): this {
202
+ return this.json(data)
203
+ }
204
+
120
205
  body(data: Buffer | string, contentType?: string): this {
121
206
  this.#body = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
122
207
  if (contentType) this.#headers['content-type'] = contentType
123
208
  return this
124
209
  }
125
210
 
126
- form(data: Record<string, string>): this {
127
- const pairs = Object.entries(data).map(
128
- ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
129
- )
211
+ form(data: Record<string, FieldValue | FieldValue[]>): this {
212
+ // Array values repeat the key (`a=1&a=2`) — Japa parity.
213
+ const pairs: string[] = []
214
+ for (const [k, v] of Object.entries(data)) {
215
+ const values = Array.isArray(v) ? v : [v]
216
+ for (const item of values) {
217
+ pairs.push(`${encodeURIComponent(k)}=${encodeURIComponent(fieldToString(item))}`)
218
+ }
219
+ }
130
220
  this.#body = Buffer.from(pairs.join('&'), 'utf8')
131
221
  this.#headers['content-type'] = 'application/x-www-form-urlencoded'
132
222
  return this
133
223
  }
134
224
 
225
+ /** Japa `.unsafeForm()` — set a urlencoded body without transform (alias of {@link form}). */
226
+ unsafeForm(data: Record<string, FieldValue | FieldValue[]>): this {
227
+ return this.form(data)
228
+ }
229
+
135
230
  /**
136
- * Add a `multipart/form-data` text field — mirrors Adonis/japa's `.field()`.
137
- * Calling `.field()` or `.file()` switches the body to multipart (use
138
- * `.form()` for `application/x-www-form-urlencoded`). The boundary +
139
- * `Content-Type` header are produced at send time.
231
+ * Add a `multipart/form-data` field — mirrors Adonis/japa's `.field()`. Accepts
232
+ * a string/number/boolean, a Buffer, a Blob, a Readable stream, or an array of
233
+ * these (repeated part). Calling `.field()`/`.fields()`/`.file()` switches the
234
+ * body to multipart; the boundary + `Content-Type` header are produced at send
235
+ * time (streams/blobs are drained to Buffers then).
140
236
  */
141
- field(name: string, value: string): this {
142
- this.#multipart.push({ kind: 'field', name, value })
237
+ field(name: string, value: MultipartValue | MultipartValue[]): this {
238
+ const values = Array.isArray(value) ? value : [value]
239
+ for (const v of values) {
240
+ this.#multipart.push({
241
+ kind: 'field',
242
+ name,
243
+ value: isScalar(v) ? fieldToString(v) : v,
244
+ })
245
+ }
246
+ return this
247
+ }
248
+
249
+ /** Add several multipart fields at once — Japa `.fields()`. */
250
+ fields(map: Record<string, MultipartValue | MultipartValue[]>): this {
251
+ for (const [name, value] of Object.entries(map)) {
252
+ this.field(name, value)
253
+ }
143
254
  return this
144
255
  }
145
256
 
146
257
  /**
147
258
  * Attach a file as a `multipart/form-data` part — mirrors Adonis/japa's
148
- * `.file(field, contents, { filename, contentType })`. `contents` is a
149
- * Buffer (binary) or a string (encoded as utf8). Server-side
150
- * `request.file(field, …)` reads the resulting part.
259
+ * `.file(field, value, { filename, contentType })`. `value` is a Buffer, a
260
+ * Blob, a Readable stream, OR a string absolute PATH to a file on disk (read at
261
+ * call time, Japa parity). The filename defaults to the path basename / field
262
+ * name; streams/blobs are drained to Buffers at send time.
151
263
  */
152
- file(field: string, contents: Buffer | string, options: FilePart = {}): this {
264
+ file(field: string, value: Buffer | string | Blob | Readable, options: FilePart = {}): this {
265
+ // A string is a PATH → read now; other kinds are resolved at send time.
266
+ const content: MultipartValue = typeof value === 'string' ? readFileSync(value) : value
267
+ const filename = options.filename ?? (typeof value === 'string' ? basename(value) : field)
153
268
  this.#multipart.push({
154
269
  kind: 'file',
155
270
  name: field,
156
- filename: options.filename ?? field,
271
+ filename,
157
272
  contentType: options.contentType ?? 'application/octet-stream',
158
- content: typeof contents === 'string' ? Buffer.from(contents, 'utf8') : contents,
273
+ content,
159
274
  })
160
275
  return this
161
276
  }
@@ -186,6 +301,15 @@ export class RequestBuilder {
186
301
  return this
187
302
  }
188
303
 
304
+ /**
305
+ * Append query params WITHOUT any validation/normalisation — Japa
306
+ * `.unsafeQs({ … })`. Values are still URL-encoded so the wire stays valid, but
307
+ * (unlike `.qs()`) no key filtering is applied; arrays repeat the key.
308
+ */
309
+ unsafeQs(params: QueryParams): this {
310
+ return this.qs(params)
311
+ }
312
+
189
313
  /** Pass a bearer token as the `Authorization` header — japa `.bearerToken()`. */
190
314
  bearerToken(token: string): this {
191
315
  this.#headers.authorization = `Bearer ${token}`
@@ -199,15 +323,70 @@ export class RequestBuilder {
199
323
  return this
200
324
  }
201
325
 
326
+ /**
327
+ * Set the request `Content-Type` from a shorthand (`'json'`, `'form'`, …) or a
328
+ * full MIME type — mirrors japa/api-client's `.type()`.
329
+ */
330
+ type(mime: string): this {
331
+ this.#headers['content-type'] = resolveMime(mime)
332
+ return this
333
+ }
334
+
335
+ /**
336
+ * Set the `Accept` header from a shorthand (`'json'`, `'html'`, …) or a full
337
+ * MIME type — mirrors japa/api-client's `.accept()`.
338
+ */
339
+ accept(mime: string): this {
340
+ this.#headers.accept = resolveMime(mime)
341
+ return this
342
+ }
343
+
344
+ /** Per-request timeout in ms — Japa `.timeout()`. Honoured by the sender. */
345
+ timeout(ms: number): this {
346
+ this.#timeoutMs = ms
347
+ return this
348
+ }
349
+
350
+ /**
351
+ * Follow up to `count` redirects before resolving — Japa `.redirects()`.
352
+ * Defaults to 5 (Japa default); pass `0` to disable following.
353
+ */
354
+ redirects(count: number): this {
355
+ this.#maxRedirects = Math.max(0, count)
356
+ return this
357
+ }
358
+
359
+ // TLS knobs — Japa parity. The test server is plain HTTP over loopback, so
360
+ // these are inert (there is no TLS handshake to configure); they exist so
361
+ // Japa-shaped code type-checks and runs unchanged. NAMED as a ream deviation.
362
+ /** No-op: the loopback test server has no TLS — Japa `.trustLocalhost()`. */
363
+ trustLocalhost(_trust = true): this {
364
+ return this
365
+ }
366
+ /** No-op: no TLS on the loopback test server — Japa `.ca()`. */
367
+ ca(_cert: string): this {
368
+ return this
369
+ }
370
+ /** No-op: no TLS on the loopback test server — Japa `.cert()`. */
371
+ cert(_chain: string): this {
372
+ return this
373
+ }
374
+ /** No-op: no TLS on the loopback test server — Japa `.privateKey()`. */
375
+ privateKey(_key: string): this {
376
+ return this
377
+ }
378
+ /** No-op: no TLS on the loopback test server — Japa `.pfx()`. */
379
+ pfx(_encoded: string | Buffer): this {
380
+ return this
381
+ }
382
+ /** No-op: no TLS on the loopback test server — Japa `.disableTLSCerts()`. */
383
+ disableTLSCerts(): this {
384
+ return this
385
+ }
386
+
202
387
  /**
203
388
  * Satisfy blackhole's signed double-submit CSRF check: echo the `XSRF-TOKEN`
204
389
  * cookie back in the `X-XSRF-TOKEN` header (the pair the server compares).
205
- *
206
- * With a `token` argument, sets both the cookie and the header — useful when
207
- * you already hold a signed token. Without one, reads the token from a cookie
208
- * set earlier on this request (typically extracted from a prior safe GET's
209
- * `Set-Cookie`); throws if none is present so the mistake is caught at the
210
- * call site instead of surfacing as a confusing 403.
211
390
  */
212
391
  withCsrf(token?: string): this {
213
392
  if (token !== undefined) this.#cookies[CSRF_COOKIE_NAME] = token
@@ -222,10 +401,14 @@ export class RequestBuilder {
222
401
  return this
223
402
  }
224
403
 
404
+ /** japa/api-client's spelling of {@link withCsrf}. */
405
+ withCsrfToken(token?: string): this {
406
+ return this.withCsrf(token)
407
+ }
408
+
225
409
  /**
226
410
  * Attach auth for a user. Uses the strategy passed to `client`'s `auth`
227
- * option — Warden's session/JWT/API-key strategy decides the header/cookie
228
- * shape. Without a strategy, throws at send time.
411
+ * option. Without a strategy, throws at send time.
229
412
  */
230
413
  withAuth(subject: AuthSubject): this {
231
414
  this.#pendingAuth = subject
@@ -238,228 +421,123 @@ export class RequestBuilder {
238
421
  return this
239
422
  }
240
423
 
241
- /** Fire the request (once) and return the raw response. Idempotent. */
242
- send(): Promise<TestResponse> {
424
+ /** Fire the request (once) and return the rich response. Idempotent. */
425
+ send(): Promise<ApiResponse> {
243
426
  if (this.#sent === null) this.#sent = this.#execute()
244
427
  return this.#sent
245
428
  }
246
429
 
247
- async expectStatus(code: number): Promise<this> {
248
- const res = await this.send()
249
- if (res.status !== code) {
250
- throw new ExpectationError(
251
- `Expected status ${code}, got ${res.status}. Body: ${capBody(res.body)}`,
252
- )
253
- }
254
- return this
430
+ /**
431
+ * Thenable — `await client.get('/x')` sends the request and resolves to the
432
+ * {@link ApiResponse} (Japa/api-client parity). Assertion methods return `this`;
433
+ * awaiting one resolves via this `then` to the response after running the checks.
434
+ */
435
+ // biome-ignore lint/suspicious/noThenProperty: the thenable IS the public API — `await client.get('/x')` is the documented Japa/api-client ergonomic; removing `.then` breaks every await-the-builder call site.
436
+ then<R1 = ApiResponse, R2 = never>(
437
+ onFulfilled?: ((value: ApiResponse) => R1 | PromiseLike<R1>) | null,
438
+ onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
439
+ ): Promise<R1 | R2> {
440
+ return this.#settle().then(onFulfilled, onRejected)
255
441
  }
256
442
 
257
- async expectHeader(name: string, value: string | RegExp): Promise<this> {
443
+ /** Send once, then run every registered assertion (throws on first failure). */
444
+ async #settle(): Promise<ApiResponse> {
258
445
  const res = await this.send()
259
- const actual = res.headers[name.toLowerCase()]
260
- if (actual === undefined) {
261
- throw new ExpectationError(
262
- `Expected header ${name}, not present in response headers: ${Object.keys(res.headers).join(', ')}`,
263
- )
264
- }
265
- if (value instanceof RegExp) {
266
- if (!value.test(actual)) {
267
- throw new ExpectationError(`Expected header ${name} to match ${value}, got "${actual}"`)
268
- }
269
- } else if (actual !== value) {
270
- throw new ExpectationError(`Expected header ${name} = "${value}", got "${actual}"`)
271
- }
272
- return this
446
+ for (const check of this.#checks) check(res)
447
+ return res
273
448
  }
274
449
 
275
- async expectCookie(name: string, value?: string | RegExp): Promise<this> {
276
- const res = await this.send()
277
- const setCookie = res.headers['set-cookie']
278
- if (!setCookie) {
279
- throw new ExpectationError(`Expected cookie ${name}, but no Set-Cookie header was returned`)
280
- }
281
- const cookies = setCookie.split(/,(?=\s*\w+=)/)
282
- const match = cookies.find((c) => c.trimStart().startsWith(`${name}=`))
283
- if (!match) {
284
- throw new ExpectationError(`Expected cookie ${name}, not found in: ${setCookie}`)
285
- }
286
- if (value === undefined) return this
287
- const rawVal = match.split(';')[0]?.split('=')[1] ?? ''
288
- if (value instanceof RegExp) {
289
- if (!value.test(rawVal)) {
290
- throw new ExpectationError(`Expected cookie ${name} to match ${value}, got "${rawVal}"`)
291
- }
292
- } else if (rawVal !== value) {
293
- throw new ExpectationError(`Expected cookie ${name} = "${value}", got "${rawVal}"`)
294
- }
450
+ /** Register a lazy assertion; returns `this` for synchronous chaining. */
451
+ #assert(check: (res: ApiResponse) => void): this {
452
+ this.#checks.push(check)
295
453
  return this
296
454
  }
297
455
 
298
- async expectJson(expected: unknown): Promise<this> {
299
- const res = await this.send()
300
- let actual: unknown
301
- try {
302
- actual = res.json()
303
- } catch (err) {
304
- throw new ExpectationError(
305
- `Expected JSON body, got non-JSON. Body: ${capBody(res.body)} (parse error: ${err instanceof Error ? err.message : String(err)})`,
306
- )
307
- }
308
- if (!partialMatch(actual, expected)) {
309
- throw new ExpectationError(
310
- `JSON partial match failed.\nExpected (partial): ${JSON.stringify(expected)}\nActual: ${capBody(JSON.stringify(actual))}`,
311
- )
312
- }
313
- return this
456
+ // ── ream-flavoured `expect*` aliases (kept for back-compat) ───────────────
457
+
458
+ expectStatus(code: number): this {
459
+ return this.#assert((res) => res.assertStatus(code))
460
+ }
461
+ expectHeader(name: string, value: string | RegExp): this {
462
+ return this.#assert((res) => res.assertHeader(name, value))
463
+ }
464
+ expectCookie(name: string, value?: string | RegExp): this {
465
+ return this.#assert((res) => res.assertCookie(name, value))
466
+ }
467
+ expectJson(expected: unknown): this {
468
+ // ream alias: partial JSON match with its own message (kept for back-compat;
469
+ // `assertBodyContains` is the Japa-named equivalent).
470
+ return this.#assert((res) => {
471
+ if (!partialMatch(res.json(), expected)) {
472
+ throw new ExpectationError(
473
+ `JSON partial match failed.\nExpected (partial): ${JSON.stringify(expected)}\nActual: ${capBody(JSON.stringify(res.json()))}`,
474
+ )
475
+ }
476
+ })
314
477
  }
315
478
 
316
- // ─── japa/api-client status shortcuts ──────────────────────
317
- // Thin aliases over expectStatus() — one per common HTTP status, matching
318
- // @japa/api-client's assertOk()/assertCreated()/… surface.
479
+ // ── Japa assertions (lazy — delegate to the response) ─────────────────────
319
480
 
320
- /** Assert a 200 OK response. */
321
- assertOk(): Promise<this> {
322
- return this.expectStatus(200)
481
+ assertStatus(code: number): this {
482
+ return this.#assert((res) => res.assertStatus(code))
323
483
  }
324
-
325
- /** Assert a 201 Created response. */
326
- assertCreated(): Promise<this> {
327
- return this.expectStatus(201)
484
+ assertHeader(name: string, value?: string | RegExp): this {
485
+ return this.#assert((res) => res.assertHeader(name, value))
328
486
  }
329
-
330
- /** Assert a 204 No Content response. */
331
- assertNoContent(): Promise<this> {
332
- return this.expectStatus(204)
487
+ assertHeaderMissing(name: string): this {
488
+ return this.#assert((res) => res.assertHeaderMissing(name))
333
489
  }
334
-
335
- /** Assert a 400 Bad Request response. */
336
- assertBadRequest(): Promise<this> {
337
- return this.expectStatus(400)
490
+ assertCookie(name: string, value?: string | RegExp): this {
491
+ return this.#assert((res) => res.assertCookie(name, value))
338
492
  }
339
-
340
- /** Assert a 401 Unauthorized response. */
341
- assertUnauthorized(): Promise<this> {
342
- return this.expectStatus(401)
493
+ assertCookieMissing(name: string): this {
494
+ return this.#assert((res) => res.assertCookieMissing(name))
343
495
  }
344
-
345
- /** Assert a 403 Forbidden response. */
346
- assertForbidden(): Promise<this> {
347
- return this.expectStatus(403)
496
+ assertBody(expected: unknown): this {
497
+ return this.#assert((res) => res.assertBody(expected))
348
498
  }
349
-
350
- /** Assert a 404 Not Found response. */
351
- assertNotFound(): Promise<this> {
352
- return this.expectStatus(404)
499
+ assertBodyContains(subset: unknown): this {
500
+ return this.#assert((res) => res.assertBodyContains(subset))
353
501
  }
354
-
355
- /**
356
- * Assert a redirect (3xx) whose `Location` resolves to `path`. Compares the
357
- * pathname (query/host ignored), mirroring japa's `assertRedirectsTo`.
358
- */
359
- async assertRedirectsTo(path: string): Promise<this> {
360
- const res = await this.send()
361
- if (res.status < 300 || res.status >= 400) {
362
- throw new ExpectationError(
363
- `Expected a redirect (3xx) to "${path}", got status ${res.status}.`,
364
- )
365
- }
366
- const location = res.headers.location
367
- if (location === undefined) {
368
- throw new ExpectationError(
369
- `Expected a redirect to "${path}", but the response has no Location header.`,
370
- )
371
- }
372
- // Location may be absolute or path-only; compare pathnames either way.
373
- const actual = location.startsWith('http')
374
- ? new URL(location).pathname
375
- : (location.split('?')[0] ?? location)
376
- if (actual !== path) {
377
- throw new ExpectationError(`Expected redirect to "${path}", got "${actual}".`)
378
- }
379
- return this
502
+ assertBodyNotContains(subset: unknown): this {
503
+ return this.#assert((res) => res.assertBodyNotContains(subset))
380
504
  }
381
-
382
- /** Assert the JSON body contains `subset` (deep partial) — japa `assertBodyContains`. */
383
- async assertBodyContains(subset: unknown): Promise<this> {
384
- const actual = await this.#bodyJson()
385
- if (!partialMatch(actual, subset)) {
386
- throw new ExpectationError(
387
- `Expected body to contain subset.\nSubset: ${JSON.stringify(subset)}\nActual: ${capBody(JSON.stringify(actual))}`,
388
- )
389
- }
390
- return this
505
+ assertTextIncludes(substring: string): this {
506
+ return this.#assert((res) => res.assertTextIncludes(substring))
391
507
  }
392
-
393
- /** Assert the JSON body does NOT contain `subset` — japa `assertBodyNotContains`. */
394
- async assertBodyNotContains(subset: unknown): Promise<this> {
395
- const actual = await this.#bodyJson()
396
- if (partialMatch(actual, subset)) {
397
- throw new ExpectationError(
398
- `Expected body NOT to contain subset, but it did.\nSubset: ${JSON.stringify(subset)}\nActual: ${capBody(JSON.stringify(actual))}`,
399
- )
400
- }
401
- return this
508
+ assertRedirectsTo(path: string): this {
509
+ return this.#assert((res) => res.assertRedirectsTo(path))
402
510
  }
403
511
 
404
- /**
405
- * Assert a response header is present, and equals `value` when given. Unlike
406
- * {@link expectHeader}, the value is optional (presence-only check).
407
- */
408
- async assertHeader(name: string, value?: string): Promise<this> {
409
- if (value !== undefined) return this.expectHeader(name, value)
410
- const res = await this.send()
411
- if (res.headers[name.toLowerCase()] === undefined) {
412
- throw new ExpectationError(
413
- `Expected header ${name}, not present in: ${Object.keys(res.headers).join(', ')}`,
414
- )
415
- }
416
- return this
417
- }
512
+ // ── Request debugging (Japa `request.dump*` — dumps the REQUEST) ──────────
418
513
 
419
- /** Assert a response header is absent — japa `assertHeaderMissing`. */
420
- async assertHeaderMissing(name: string): Promise<this> {
421
- const res = await this.send()
422
- if (res.headers[name.toLowerCase()] !== undefined) {
423
- throw new ExpectationError(`Expected header ${name} to be absent, but it was present.`)
424
- }
514
+ /** Print the pending request (method/path/headers/body) — Japa `request.dump()`. */
515
+ dump(): this {
516
+ process.stderr.write(
517
+ `[helix:request.dump] ${this.#method} ${this.#path}\n` +
518
+ `headers: ${JSON.stringify(this.#headers, null, 2)}\n` +
519
+ `cookies: ${JSON.stringify(this.#cookies, null, 2)}\n` +
520
+ `body: ${capBody(this.#body.toString('utf8'), 2048)}\n`,
521
+ )
425
522
  return this
426
523
  }
427
-
428
- /** Assert the response set a cookie, optionally with a given value — japa `assertCookie`. */
429
- assertCookie(name: string, value?: string): Promise<this> {
430
- return this.expectCookie(name, value)
524
+ /** Print only the pending request body — Japa `request.dumpBody()`. */
525
+ dumpBody(): this {
526
+ process.stderr.write(`[helix:request.dumpBody] ${capBody(this.#body.toString('utf8'), 2048)}\n`)
527
+ return this
431
528
  }
432
-
433
- /** Assert the response did NOT set a cookie — japa `assertCookieMissing`. */
434
- async assertCookieMissing(name: string): Promise<this> {
435
- const res = await this.send()
436
- const setCookie = res.headers['set-cookie']
437
- if (setCookie) {
438
- const present = setCookie
439
- .split(/,(?=\s*\w+=)/)
440
- .some((c) => c.trimStart().startsWith(`${name}=`))
441
- if (present) {
442
- throw new ExpectationError(
443
- `Expected cookie ${name} to be absent, but it was set: ${setCookie}`,
444
- )
445
- }
446
- }
529
+ /** Print only the pending request headers — Japa `request.dumpHeaders()`. */
530
+ dumpHeaders(): this {
531
+ process.stderr.write(`[helix:request.dumpHeaders] ${JSON.stringify(this.#headers, null, 2)}\n`)
447
532
  return this
448
533
  }
449
-
450
- /** Send (once) and parse the body as JSON, wrapping parse errors as ExpectationError. */
451
- async #bodyJson(): Promise<unknown> {
452
- const res = await this.send()
453
- try {
454
- return res.json()
455
- } catch (err) {
456
- throw new ExpectationError(
457
- `Expected JSON body, got non-JSON. Body: ${capBody(res.body)} (parse error: ${err instanceof Error ? err.message : String(err)})`,
458
- )
459
- }
534
+ /** Print only the pending request cookies — Japa `request.dumpCookies()`. */
535
+ dumpCookies(): this {
536
+ process.stderr.write(`[helix:request.dumpCookies] ${JSON.stringify(this.#cookies, null, 2)}\n`)
537
+ return this
460
538
  }
461
539
 
462
- async #execute(): Promise<TestResponse> {
540
+ async #execute(): Promise<ApiResponse> {
463
541
  // Resolve auth before sending.
464
542
  if (this.#pendingAuth !== null) {
465
543
  if (!this.#authStrategy) {
@@ -486,7 +564,21 @@ export class RequestBuilder {
486
564
  // json()/form()/body(); encode them with a fresh boundary at send time.
487
565
  if (this.#multipart.length > 0) {
488
566
  const boundary = `----ReamRequestBuilder${crypto.randomUUID().replace(/-/g, '')}`
489
- this.#body = encodeMultipart(this.#multipart, boundary)
567
+ // Drain any stream/blob part to a Buffer before encoding (Japa parity).
568
+ const resolved: ResolvedPart[] = await Promise.all(
569
+ this.#multipart.map(async (p) =>
570
+ p.kind === 'field'
571
+ ? { kind: 'field' as const, name: p.name, value: await toBuffer(p.value) }
572
+ : {
573
+ kind: 'file' as const,
574
+ name: p.name,
575
+ filename: p.filename,
576
+ contentType: p.contentType,
577
+ content: await toBuffer(p.content),
578
+ },
579
+ ),
580
+ )
581
+ this.#body = encodeMultipart(resolved, boundary)
490
582
  this.#headers['content-type'] = `multipart/form-data; boundary=${boundary}`
491
583
  }
492
584
 
@@ -496,23 +588,178 @@ export class RequestBuilder {
496
588
  this.#headers.cookie = cookieEntries.map(([k, v]) => `${k}=${v}`).join('; ')
497
589
  }
498
590
 
499
- // Merge `.qs()` params into the path, preserving any query string it
500
- // already carries.
591
+ // Merge `.qs()` params into the path, preserving any existing query string.
501
592
  let path = this.#path
502
593
  const qs = this.#query.toString()
503
594
  if (qs) {
504
595
  path += (path.includes('?') ? '&' : '?') + qs
505
596
  }
506
597
 
507
- return this.#sender(this.#method, path, {
598
+ let method = this.#method
599
+ let body = this.#body
600
+ let raw = await this.#sender(method, path, {
508
601
  headers: this.#headers,
509
- body: this.#body,
602
+ body,
603
+ timeoutMs: this.#timeoutMs,
510
604
  })
605
+
606
+ // Follow redirects up to the configured budget — Japa `.redirects()` (5 by default).
607
+ const chain: string[] = []
608
+ let followed = 0
609
+ while (
610
+ followed < this.#maxRedirects &&
611
+ REDIRECT_STATUSES.has(raw.status) &&
612
+ raw.headers.location !== undefined
613
+ ) {
614
+ const location = raw.headers.location
615
+ chain.push(location)
616
+ followed += 1
617
+ path = location.startsWith('http')
618
+ ? new URL(location).pathname + new URL(location).search
619
+ : location
620
+ // 303 (and, per browser convention, 301/302) switch to GET with no body;
621
+ // 307/308 preserve the method and body.
622
+ if (raw.status === 307 || raw.status === 308) {
623
+ // keep method + body
624
+ } else {
625
+ method = 'GET'
626
+ body = Buffer.alloc(0)
627
+ delete this.#headers['content-type']
628
+ }
629
+ raw = await this.#sender(method, path, {
630
+ headers: this.#headers,
631
+ body,
632
+ timeoutMs: this.#timeoutMs,
633
+ })
634
+ }
635
+
636
+ return new ApiResponse(raw, { method, redirects: chain })
637
+ }
638
+
639
+ /**
640
+ * Serialize `data` with a registered serializer and set it as the request body
641
+ * — the send-side twin of {@link ApiResponse.addParser}. Register serializers
642
+ * via {@link RequestBuilder.addSerializer} (Japa `ApiRequest.addSerializer`).
643
+ */
644
+ serialize(name: string, data: unknown): this {
645
+ const serializer = requestSerializers.get(name)
646
+ if (!serializer) {
647
+ throw new Error(
648
+ `RequestBuilder: no serializer registered for "${name}" (see RequestBuilder.addSerializer).`,
649
+ )
650
+ }
651
+ const { body, contentType } = serializer(data)
652
+ this.#body = body
653
+ this.#headers['content-type'] = contentType
654
+ return this
655
+ }
656
+
657
+ /**
658
+ * Register a shared property (Japa `ApiRequest.macro`) or lazy getter
659
+ * (`ApiRequest.getter`). The callback is invoked with `this` bound to the
660
+ * builder AND the builder as its first arg, so both Japa's
661
+ * `function () { return this.header('x') }` and `(req) => …` work.
662
+ */
663
+ static macro(name: string, value: unknown): void {
664
+ requestMacros.set(name, value)
665
+ }
666
+ static getter(name: string, fn: (this: RequestBuilder, req: RequestBuilder) => unknown): void {
667
+ requestGetters.set(name, fn)
668
+ }
669
+
670
+ /** Register a request body serializer — Japa `ApiRequest.addSerializer`. */
671
+ static addSerializer(
672
+ name: string,
673
+ fn: (data: unknown) => { body: Buffer; contentType: string },
674
+ ): void {
675
+ requestSerializers.set(name, fn)
676
+ }
677
+
678
+ /** Register a response body parser — Japa `ApiRequest.addParser` (delegates to ApiResponse). */
679
+ static addParser(
680
+ contentType: string,
681
+ fn: (body: string, headers: Record<string, string>) => unknown,
682
+ ): void {
683
+ ApiResponse.addParser(contentType, fn)
684
+ }
685
+ }
686
+
687
+ /** The status-shortcut assertions (lazy) are typed onto the builder. */
688
+ // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: the merged interface (StatusAssertions) types the status-shortcut asserts attached from STATUS_ASSERTIONS at load — the same generated-method pattern as EonSchema/Macroable (AdonisJS parity); every member is implemented, so the merge is safe.
689
+ export interface RequestBuilder extends StatusAssertions<RequestBuilder> {}
690
+
691
+ // Attach the lazy status-shortcut assertions from the shared map so the builder
692
+ // and ApiResponse never disagree on the code set. Each defers to the public
693
+ // `expectStatus` (private fields aren't reachable from prototype-added fns).
694
+ for (const [method, code] of Object.entries(STATUS_ASSERTIONS)) {
695
+ Object.defineProperty(RequestBuilder.prototype, method, {
696
+ value(this: RequestBuilder) {
697
+ return this.expectStatus(code)
698
+ },
699
+ enumerable: false,
700
+ configurable: true,
701
+ writable: true,
702
+ })
703
+ }
704
+
705
+ /** Coerce a field value to its wire string (Buffers are handled separately). */
706
+ function fieldToString(value: FieldValue): string {
707
+ return Buffer.isBuffer(value) ? value.toString('utf8') : String(value)
708
+ }
709
+
710
+ /** Whether a multipart value is a scalar (string/number/boolean/Buffer), not a stream/blob. */
711
+ function isScalar(v: MultipartValue): v is FieldValue {
712
+ return (
713
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Buffer.isBuffer(v)
714
+ )
715
+ }
716
+
717
+ /** Drain a multipart value (string/Buffer/Blob/Readable) to a Buffer at send time. */
718
+ async function toBuffer(v: MultipartValue): Promise<Buffer> {
719
+ if (Buffer.isBuffer(v)) return v
720
+ if (typeof v === 'string') return Buffer.from(v, 'utf8')
721
+ if (typeof v === 'number' || typeof v === 'boolean') return Buffer.from(String(v), 'utf8')
722
+ // `in` narrows the Blob | Readable union without a cast: only Blob has arrayBuffer.
723
+ if ('arrayBuffer' in v) return Buffer.from(await v.arrayBuffer())
724
+ // Readable / async-iterable → collect chunks.
725
+ const chunks: Buffer[] = []
726
+ for await (const chunk of v) {
727
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
511
728
  }
729
+ return Buffer.concat(chunks)
512
730
  }
513
731
 
514
- function capBody(s: string, max = 512): string {
515
- return s.length <= max ? s : `${s.slice(0, max)}...[truncated]`
732
+ /** A multipart part whose value/content has been resolved to a Buffer (send time). */
733
+ type ResolvedPart =
734
+ | { kind: 'field'; name: string; value: Buffer }
735
+ | { kind: 'file'; name: string; filename: string; contentType: string; content: Buffer }
736
+
737
+ /** Apply registered request macros/getters onto a freshly built builder. */
738
+ function applyRequestExtensions(req: RequestBuilder): void {
739
+ for (const [name, value] of requestMacros) {
740
+ Object.defineProperty(req, name, {
741
+ value,
742
+ enumerable: true,
743
+ configurable: true,
744
+ writable: true,
745
+ })
746
+ }
747
+ for (const [name, fn] of requestGetters) {
748
+ let computed = false
749
+ let cached: unknown
750
+ Object.defineProperty(req, name, {
751
+ enumerable: true,
752
+ configurable: true,
753
+ get() {
754
+ if (!computed) {
755
+ // `this`-bound AND passed as arg → both `function(){this.x}` and `(req)=>req.x` work.
756
+ cached = fn.call(req, req)
757
+ computed = true
758
+ }
759
+ return cached
760
+ },
761
+ })
762
+ }
516
763
  }
517
764
 
518
765
  /** Strip CR/LF and escape quotes so a name/filename can't break the headers. */
@@ -520,19 +767,21 @@ function sanitizeHeaderParam(value: string): string {
520
767
  return value.replace(/[\r\n]/g, '').replace(/"/g, '%22')
521
768
  }
522
769
 
523
- /** Encode multipart/form-data parts (RFC 7578) into a single body Buffer. */
524
- function encodeMultipart(parts: MultipartPart[], boundary: string): Buffer {
770
+ /** Encode resolved multipart/form-data parts (RFC 7578) into a single body Buffer. */
771
+ function encodeMultipart(parts: ResolvedPart[], boundary: string): Buffer {
525
772
  const CRLF = '\r\n'
526
773
  const chunks: Buffer[] = []
527
774
  for (const part of parts) {
528
775
  if (part.kind === 'field') {
776
+ const valueBuf = part.value
529
777
  chunks.push(
530
778
  Buffer.from(
531
779
  `--${boundary}${CRLF}` +
532
- `Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"${CRLF}${CRLF}` +
533
- `${part.value}${CRLF}`,
780
+ `Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"${CRLF}${CRLF}`,
534
781
  'utf8',
535
782
  ),
783
+ valueBuf,
784
+ Buffer.from(CRLF, 'utf8'),
536
785
  )
537
786
  } else {
538
787
  chunks.push(
@@ -550,29 +799,3 @@ function encodeMultipart(parts: MultipartPart[], boundary: string): Buffer {
550
799
  chunks.push(Buffer.from(`--${boundary}--${CRLF}`, 'utf8'))
551
800
  return Buffer.concat(chunks)
552
801
  }
553
-
554
- /** Narrowing guard — a non-null, non-array object usable as a string map. */
555
- function isRecord(x: unknown): x is Record<string, unknown> {
556
- return typeof x === 'object' && x !== null && !Array.isArray(x)
557
- }
558
-
559
- /**
560
- * Partial deep-match. For objects, every key in `expected` must match in
561
- * `actual`. For arrays, every element in `expected` must match SOMEWHERE in
562
- * `actual` (order-independent). Primitives compared by strict equality.
563
- */
564
- export function partialMatch(actual: unknown, expected: unknown): boolean {
565
- if (expected === null || expected === undefined) {
566
- return actual === expected
567
- }
568
- if (typeof expected !== 'object') return actual === expected
569
- if (Array.isArray(expected)) {
570
- if (!Array.isArray(actual)) return false
571
- return expected.every((want) => actual.some((have) => partialMatch(have, want)))
572
- }
573
- if (!isRecord(actual) || !isRecord(expected)) return false
574
- for (const key of Object.keys(expected)) {
575
- if (!partialMatch(actual[key], expected[key])) return false
576
- }
577
- return true
578
- }