@c9up/ream 0.1.25 → 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 (302) hide show
  1. package/README.md +65 -0
  2. package/dist/ConfigLoader.d.ts +46 -5
  3. package/dist/ConfigLoader.d.ts.map +1 -1
  4. package/dist/ConfigLoader.js +60 -9
  5. package/dist/ConfigLoader.js.map +1 -1
  6. package/dist/HttpKernel.d.ts.map +1 -1
  7. package/dist/HttpKernel.js +46 -6
  8. package/dist/HttpKernel.js.map +1 -1
  9. package/dist/Ignitor.d.ts +115 -7
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +254 -40
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/console/BaseCommand.d.ts +217 -0
  14. package/dist/console/BaseCommand.d.ts.map +1 -0
  15. package/dist/console/BaseCommand.js +340 -0
  16. package/dist/console/BaseCommand.js.map +1 -0
  17. package/dist/console/Console.d.ts +86 -0
  18. package/dist/console/Console.d.ts.map +1 -0
  19. package/dist/console/Console.js +134 -0
  20. package/dist/console/Console.js.map +1 -0
  21. package/dist/console/ExceptionHandler.d.ts +28 -0
  22. package/dist/console/ExceptionHandler.d.ts.map +1 -0
  23. package/dist/console/ExceptionHandler.js +76 -0
  24. package/dist/console/ExceptionHandler.js.map +1 -0
  25. package/dist/console/HelpCommand.d.ts +16 -0
  26. package/dist/console/HelpCommand.d.ts.map +1 -0
  27. package/dist/console/HelpCommand.js +40 -0
  28. package/dist/console/HelpCommand.js.map +1 -0
  29. package/dist/console/IndexGenerator.d.ts +24 -0
  30. package/dist/console/IndexGenerator.d.ts.map +1 -0
  31. package/dist/console/IndexGenerator.js +65 -0
  32. package/dist/console/IndexGenerator.js.map +1 -0
  33. package/dist/console/Kernel.d.ts +298 -0
  34. package/dist/console/Kernel.d.ts.map +1 -0
  35. package/dist/console/Kernel.js +1120 -0
  36. package/dist/console/Kernel.js.map +1 -0
  37. package/dist/console/ListCommand.d.ts +18 -0
  38. package/dist/console/ListCommand.d.ts.map +1 -0
  39. package/dist/console/ListCommand.js +60 -0
  40. package/dist/console/ListCommand.js.map +1 -0
  41. package/dist/console/assertions.d.ts +20 -0
  42. package/dist/console/assertions.d.ts.map +1 -0
  43. package/dist/console/assertions.js +84 -0
  44. package/dist/console/assertions.js.map +1 -0
  45. package/dist/console/cliui.d.ts +248 -0
  46. package/dist/console/cliui.d.ts.map +1 -0
  47. package/dist/console/cliui.js +587 -0
  48. package/dist/console/cliui.js.map +1 -0
  49. package/dist/console/decorators.d.ts +90 -0
  50. package/dist/console/decorators.d.ts.map +1 -0
  51. package/dist/console/decorators.js +142 -0
  52. package/dist/console/decorators.js.map +1 -0
  53. package/dist/console/index.d.ts +32 -0
  54. package/dist/console/index.d.ts.map +1 -0
  55. package/dist/console/index.js +24 -0
  56. package/dist/console/index.js.map +1 -0
  57. package/dist/console/loaders.d.ts +52 -0
  58. package/dist/console/loaders.d.ts.map +1 -0
  59. package/dist/console/loaders.js +131 -0
  60. package/dist/console/loaders.js.map +1 -0
  61. package/dist/console/parser.d.ts +86 -0
  62. package/dist/console/parser.d.ts.map +1 -0
  63. package/dist/console/parser.js +440 -0
  64. package/dist/console/parser.js.map +1 -0
  65. package/dist/console/prompts.d.ts +124 -0
  66. package/dist/console/prompts.d.ts.map +1 -0
  67. package/dist/console/prompts.js +356 -0
  68. package/dist/console/prompts.js.map +1 -0
  69. package/dist/console/tracing.d.ts +17 -0
  70. package/dist/console/tracing.d.ts.map +1 -0
  71. package/dist/console/tracing.js +11 -0
  72. package/dist/console/tracing.js.map +1 -0
  73. package/dist/console/types.d.ts +255 -0
  74. package/dist/console/types.d.ts.map +1 -0
  75. package/dist/console/types.js +25 -0
  76. package/dist/console/types.js.map +1 -0
  77. package/dist/console/ui.d.ts +20 -0
  78. package/dist/console/ui.d.ts.map +1 -0
  79. package/dist/console/ui.js +31 -0
  80. package/dist/console/ui.js.map +1 -0
  81. package/dist/console/utils.d.ts +27 -0
  82. package/dist/console/utils.d.ts.map +1 -0
  83. package/dist/console/utils.js +56 -0
  84. package/dist/console/utils.js.map +1 -0
  85. package/dist/container/Container.d.ts +23 -3
  86. package/dist/container/Container.d.ts.map +1 -1
  87. package/dist/container/Container.js +177 -70
  88. package/dist/container/Container.js.map +1 -1
  89. package/dist/container/types.d.ts +14 -2
  90. package/dist/container/types.d.ts.map +1 -1
  91. package/dist/decorators/Service.d.ts +7 -0
  92. package/dist/decorators/Service.d.ts.map +1 -1
  93. package/dist/decorators/Service.js +23 -3
  94. package/dist/decorators/Service.js.map +1 -1
  95. package/dist/env/Env.d.ts +29 -4
  96. package/dist/env/Env.d.ts.map +1 -1
  97. package/dist/env/Env.js +27 -4
  98. package/dist/env/Env.js.map +1 -1
  99. package/dist/env/Secret.d.ts +11 -0
  100. package/dist/env/Secret.d.ts.map +1 -0
  101. package/dist/env/Secret.js +36 -0
  102. package/dist/env/Secret.js.map +1 -0
  103. package/dist/env/interpolate.d.ts +26 -0
  104. package/dist/env/interpolate.d.ts.map +1 -0
  105. package/dist/env/interpolate.js +48 -0
  106. package/dist/env/interpolate.js.map +1 -0
  107. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  108. package/dist/env/loadEnvFiles.js +14 -4
  109. package/dist/env/loadEnvFiles.js.map +1 -1
  110. package/dist/env/schema.d.ts +23 -6
  111. package/dist/env/schema.d.ts.map +1 -1
  112. package/dist/env/schema.js +67 -33
  113. package/dist/env/schema.js.map +1 -1
  114. package/dist/errors/ReamError.d.ts +25 -0
  115. package/dist/errors/ReamError.d.ts.map +1 -1
  116. package/dist/errors/ReamError.js +5 -0
  117. package/dist/errors/ReamError.js.map +1 -1
  118. package/dist/errors/prettyPrintError.d.ts +9 -0
  119. package/dist/errors/prettyPrintError.d.ts.map +1 -0
  120. package/dist/errors/prettyPrintError.js +22 -0
  121. package/dist/errors/prettyPrintError.js.map +1 -0
  122. package/dist/events/Emitter.d.ts +1 -1
  123. package/dist/events/Emitter.d.ts.map +1 -1
  124. package/dist/events/Emitter.js +1 -1
  125. package/dist/events/Emitter.js.map +1 -1
  126. package/dist/events/EventsProvider.d.ts +2 -2
  127. package/dist/events/EventsProvider.d.ts.map +1 -1
  128. package/dist/events/EventsProvider.js +3 -3
  129. package/dist/events/EventsProvider.js.map +1 -1
  130. package/dist/graphql/GraphQLEngine.d.ts +1 -1
  131. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  132. package/dist/graphql/GraphQLEngine.js +2 -2
  133. package/dist/graphql/GraphQLEngine.js.map +1 -1
  134. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  135. package/dist/graphql/GraphQLProvider.js +7 -3
  136. package/dist/graphql/GraphQLProvider.js.map +1 -1
  137. package/dist/http/Exception.d.ts +63 -1
  138. package/dist/http/Exception.d.ts.map +1 -1
  139. package/dist/http/Exception.js +185 -48
  140. package/dist/http/Exception.js.map +1 -1
  141. package/dist/http/HttpContext.d.ts +53 -7
  142. package/dist/http/HttpContext.d.ts.map +1 -1
  143. package/dist/http/HttpContext.js +56 -15
  144. package/dist/http/HttpContext.js.map +1 -1
  145. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  146. package/dist/http/RedirectBuilder.js +4 -1
  147. package/dist/http/RedirectBuilder.js.map +1 -1
  148. package/dist/http/Request.d.ts +141 -10
  149. package/dist/http/Request.d.ts.map +1 -1
  150. package/dist/http/Request.js +244 -34
  151. package/dist/http/Request.js.map +1 -1
  152. package/dist/http/Response.d.ts +176 -9
  153. package/dist/http/Response.d.ts.map +1 -1
  154. package/dist/http/Response.js +404 -16
  155. package/dist/http/Response.js.map +1 -1
  156. package/dist/index.d.ts +19 -8
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +20 -5
  159. package/dist/index.js.map +1 -1
  160. package/dist/middleware/Pipeline.d.ts +29 -8
  161. package/dist/middleware/Pipeline.d.ts.map +1 -1
  162. package/dist/middleware/Pipeline.js +40 -2
  163. package/dist/middleware/Pipeline.js.map +1 -1
  164. package/dist/openapi/OpenApiProvider.js +4 -4
  165. package/dist/openapi/OpenApiProvider.js.map +1 -1
  166. package/dist/router/Router.d.ts +223 -31
  167. package/dist/router/Router.d.ts.map +1 -1
  168. package/dist/router/Router.js +520 -102
  169. package/dist/router/Router.js.map +1 -1
  170. package/dist/rpc/RpcProvider.d.ts +1 -0
  171. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  172. package/dist/rpc/RpcProvider.js +7 -3
  173. package/dist/rpc/RpcProvider.js.map +1 -1
  174. package/dist/rpc/RpcRouter.d.ts +2 -2
  175. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  176. package/dist/rpc/RpcRouter.js +17 -5
  177. package/dist/rpc/RpcRouter.js.map +1 -1
  178. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  179. package/dist/scheduler/ScheduleProvider.js +7 -15
  180. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  181. package/dist/server/Server.d.ts +17 -4
  182. package/dist/server/Server.d.ts.map +1 -1
  183. package/dist/server/Server.js +41 -7
  184. package/dist/server/Server.js.map +1 -1
  185. package/dist/services/app.d.ts +1 -1
  186. package/dist/services/app.js +1 -1
  187. package/dist/services/console.d.ts +21 -0
  188. package/dist/services/console.d.ts.map +1 -0
  189. package/dist/services/console.js +35 -0
  190. package/dist/services/console.js.map +1 -0
  191. package/dist/session/SessionMiddleware.js +3 -3
  192. package/dist/session/SessionMiddleware.js.map +1 -1
  193. package/dist/storage/StaticProvider.js +1 -1
  194. package/dist/storage/StaticProvider.js.map +1 -1
  195. package/dist/testing/ApiResponse.d.ts +218 -0
  196. package/dist/testing/ApiResponse.d.ts.map +1 -0
  197. package/dist/testing/ApiResponse.js +576 -0
  198. package/dist/testing/ApiResponse.js.map +1 -0
  199. package/dist/testing/RequestBuilder.d.ts +155 -35
  200. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  201. package/dist/testing/RequestBuilder.js +455 -109
  202. package/dist/testing/RequestBuilder.js.map +1 -1
  203. package/dist/testing/TestClient.d.ts +53 -37
  204. package/dist/testing/TestClient.d.ts.map +1 -1
  205. package/dist/testing/TestClient.js +84 -52
  206. package/dist/testing/TestClient.js.map +1 -1
  207. package/dist/utils/Macroable.d.ts +30 -0
  208. package/dist/utils/Macroable.d.ts.map +1 -0
  209. package/dist/utils/Macroable.js +43 -0
  210. package/dist/utils/Macroable.js.map +1 -0
  211. package/dist/utils/inflect.d.ts +17 -0
  212. package/dist/utils/inflect.d.ts.map +1 -0
  213. package/dist/utils/inflect.js +32 -0
  214. package/dist/utils/inflect.js.map +1 -0
  215. package/dist/utils/objectPath.d.ts +57 -0
  216. package/dist/utils/objectPath.d.ts.map +1 -0
  217. package/dist/utils/objectPath.js +141 -0
  218. package/dist/utils/objectPath.js.map +1 -0
  219. package/events.darwin-arm64.node +0 -0
  220. package/events.darwin-x64.node +0 -0
  221. package/events.linux-arm64-gnu.node +0 -0
  222. package/events.linux-x64-gnu.node +0 -0
  223. package/events.win32-x64-msvc.node +0 -0
  224. package/index.darwin-arm64.node +0 -0
  225. package/index.darwin-x64.node +0 -0
  226. package/index.linux-arm64-gnu.node +0 -0
  227. package/index.linux-x64-gnu.node +0 -0
  228. package/index.win32-x64-msvc.node +0 -0
  229. package/package.json +20 -2
  230. package/scheduler.darwin-arm64.node +0 -0
  231. package/scheduler.darwin-x64.node +0 -0
  232. package/scheduler.linux-arm64-gnu.node +0 -0
  233. package/scheduler.linux-x64-gnu.node +0 -0
  234. package/scheduler.win32-x64-msvc.node +0 -0
  235. package/src/ConfigLoader.ts +72 -9
  236. package/src/HttpKernel.ts +53 -15
  237. package/src/Ignitor.ts +364 -45
  238. package/src/console/BaseCommand.ts +467 -0
  239. package/src/console/Console.ts +174 -0
  240. package/src/console/ExceptionHandler.ts +91 -0
  241. package/src/console/HelpCommand.ts +37 -0
  242. package/src/console/IndexGenerator.ts +79 -0
  243. package/src/console/Kernel.ts +1382 -0
  244. package/src/console/ListCommand.ts +55 -0
  245. package/src/console/assertions.ts +114 -0
  246. package/src/console/cliui.ts +756 -0
  247. package/src/console/decorators.ts +199 -0
  248. package/src/console/index.ts +67 -0
  249. package/src/console/loaders.ts +145 -0
  250. package/src/console/parser.ts +598 -0
  251. package/src/console/prompts.ts +548 -0
  252. package/src/console/tracing.ts +22 -0
  253. package/src/console/types.ts +280 -0
  254. package/src/console/ui.ts +37 -0
  255. package/src/console/utils.ts +69 -0
  256. package/src/container/Container.ts +198 -72
  257. package/src/container/types.ts +15 -2
  258. package/src/decorators/Service.ts +28 -3
  259. package/src/env/Env.ts +49 -5
  260. package/src/env/Secret.ts +41 -0
  261. package/src/env/interpolate.ts +54 -0
  262. package/src/env/loadEnvFiles.ts +12 -4
  263. package/src/env/schema.ts +109 -35
  264. package/src/errors/ReamError.ts +31 -0
  265. package/src/errors/prettyPrintError.ts +20 -0
  266. package/src/events/Emitter.ts +2 -2
  267. package/src/events/EventsProvider.ts +5 -5
  268. package/src/graphql/GraphQLEngine.ts +4 -4
  269. package/src/graphql/GraphQLProvider.ts +7 -3
  270. package/src/http/Exception.ts +214 -37
  271. package/src/http/HttpContext.ts +87 -18
  272. package/src/http/RedirectBuilder.ts +4 -1
  273. package/src/http/Request.ts +264 -34
  274. package/src/http/Response.ts +478 -27
  275. package/src/index.ts +41 -6
  276. package/src/middleware/Pipeline.ts +77 -6
  277. package/src/openapi/OpenApiProvider.ts +4 -4
  278. package/src/router/Router.ts +631 -113
  279. package/src/rpc/RpcProvider.ts +6 -2
  280. package/src/rpc/RpcRouter.ts +22 -7
  281. package/src/scheduler/ScheduleProvider.ts +7 -15
  282. package/src/server/Server.ts +49 -10
  283. package/src/services/app.ts +1 -1
  284. package/src/services/console.ts +42 -0
  285. package/src/session/SessionMiddleware.ts +4 -4
  286. package/src/storage/StaticProvider.ts +1 -1
  287. package/src/testing/ApiResponse.ts +691 -0
  288. package/src/testing/RequestBuilder.ts +577 -137
  289. package/src/testing/TestClient.ts +110 -77
  290. package/src/utils/Macroable.ts +54 -0
  291. package/src/utils/inflect.ts +29 -0
  292. package/src/utils/objectPath.ts +142 -0
  293. package/dist/console/CommandRunner.d.ts +0 -18
  294. package/dist/console/CommandRunner.d.ts.map +0 -1
  295. package/dist/console/CommandRunner.js +0 -65
  296. package/dist/console/CommandRunner.js.map +0 -1
  297. package/dist/decorators/Lazy.d.ts +0 -19
  298. package/dist/decorators/Lazy.d.ts.map +0 -1
  299. package/dist/decorators/Lazy.js +0 -53
  300. package/dist/decorators/Lazy.js.map +0 -1
  301. package/src/console/CommandRunner.ts +0 -82
  302. package/src/decorators/Lazy.ts +0 -57
@@ -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,15 +53,52 @@ 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
- }
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 & {})
72
+
73
+ /** Cookie the signed CSRF token is issued in (blackhole default: `XSRF-TOKEN`). */
74
+ const CSRF_COOKIE_NAME = 'XSRF-TOKEN'
75
+ /** Header the client echoes the token back in (Axios/Angular default). */
76
+ const CSRF_HEADER_NAME = 'x-xsrf-token'
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
37
91
  }
38
92
 
39
- export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
93
+ /** Primitive accepted as a query-string value (mirrors `.qs()` in japa/api-client). */
94
+ type QueryValue = string | number | boolean
95
+ /** Query-string map — a value or an array of values (repeated key). */
96
+ export type QueryParams = Record<string, QueryValue | ReadonlyArray<QueryValue>>
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
40
102
 
41
103
  /**
42
104
  * Internal low-level sender — matches what the TestClient exposes.
@@ -48,22 +110,42 @@ export type HttpSender = (
48
110
  init: {
49
111
  headers: Record<string, string>
50
112
  body: Buffer
113
+ /** Per-request socket timeout (ms) — Japa `.timeout()`. Sender may honour it. */
114
+ timeoutMs?: number
51
115
  },
52
116
  ) => Promise<TestResponse>
53
117
 
54
118
  /** Options for a multipart file part — mirrors Adonis/japa's `.file()` options. */
55
119
  export interface FilePart {
56
- /** 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. */
57
121
  filename?: string
58
122
  /** MIME type of the part. Defaults to `application/octet-stream`. */
59
123
  contentType?: string
60
124
  }
61
125
 
62
- /** 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
+ */
63
130
  type MultipartPart =
64
- | { kind: 'field'; name: string; value: string }
65
- | { kind: 'file'; name: string; filename: string; contentType: string; content: Buffer }
66
-
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.
67
149
  export class RequestBuilder {
68
150
  #sender: HttpSender
69
151
  #method: HttpMethod
@@ -72,9 +154,16 @@ export class RequestBuilder {
72
154
  #body: Buffer = Buffer.alloc(0)
73
155
  #multipart: MultipartPart[] = []
74
156
  #cookies: Record<string, string> = {}
157
+ #query = new URLSearchParams()
75
158
  #authStrategy: AuthStrategy | null
76
159
  #pendingAuth: AuthSubject | null = null
77
- #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> = []
78
167
 
79
168
  constructor(
80
169
  sender: HttpSender,
@@ -86,6 +175,7 @@ export class RequestBuilder {
86
175
  this.#method = method
87
176
  this.#path = path
88
177
  this.#authStrategy = authStrategy
178
+ applyRequestExtensions(this)
89
179
  }
90
180
 
91
181
  headers(map: Record<string, string>): this {
@@ -95,8 +185,9 @@ export class RequestBuilder {
95
185
  return this
96
186
  }
97
187
 
98
- header(name: string, value: string): this {
99
- 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
100
191
  return this
101
192
  }
102
193
 
@@ -106,45 +197,80 @@ export class RequestBuilder {
106
197
  return this
107
198
  }
108
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
+
109
205
  body(data: Buffer | string, contentType?: string): this {
110
206
  this.#body = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
111
207
  if (contentType) this.#headers['content-type'] = contentType
112
208
  return this
113
209
  }
114
210
 
115
- form(data: Record<string, string>): this {
116
- const pairs = Object.entries(data).map(
117
- ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
118
- )
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
+ }
119
220
  this.#body = Buffer.from(pairs.join('&'), 'utf8')
120
221
  this.#headers['content-type'] = 'application/x-www-form-urlencoded'
121
222
  return this
122
223
  }
123
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
+
124
230
  /**
125
- * Add a `multipart/form-data` text field — mirrors Adonis/japa's `.field()`.
126
- * Calling `.field()` or `.file()` switches the body to multipart (use
127
- * `.form()` for `application/x-www-form-urlencoded`). The boundary +
128
- * `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).
129
236
  */
130
- field(name: string, value: string): this {
131
- 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
+ }
132
254
  return this
133
255
  }
134
256
 
135
257
  /**
136
258
  * Attach a file as a `multipart/form-data` part — mirrors Adonis/japa's
137
- * `.file(field, contents, { filename, contentType })`. `contents` is a
138
- * Buffer (binary) or a string (encoded as utf8). Server-side
139
- * `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.
140
263
  */
141
- 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)
142
268
  this.#multipart.push({
143
269
  kind: 'file',
144
270
  name: field,
145
- filename: options.filename ?? field,
271
+ filename,
146
272
  contentType: options.contentType ?? 'application/octet-stream',
147
- content: typeof contents === 'string' ? Buffer.from(contents, 'utf8') : contents,
273
+ content,
148
274
  })
149
275
  return this
150
276
  }
@@ -159,10 +285,130 @@ export class RequestBuilder {
159
285
  return this
160
286
  }
161
287
 
288
+ /**
289
+ * Append query-string params — mirrors japa/api-client's `.qs()`. Values are
290
+ * URL-encoded; arrays repeat the key (`?tag=a&tag=b`). Merges with any query
291
+ * string already on the path and with earlier `.qs()` calls.
292
+ */
293
+ qs(params: QueryParams): this {
294
+ for (const [key, value] of Object.entries(params)) {
295
+ if (Array.isArray(value)) {
296
+ for (const item of value) this.#query.append(key, String(item))
297
+ } else {
298
+ this.#query.append(key, String(value))
299
+ }
300
+ }
301
+ return this
302
+ }
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
+
313
+ /** Pass a bearer token as the `Authorization` header — japa `.bearerToken()`. */
314
+ bearerToken(token: string): this {
315
+ this.#headers.authorization = `Bearer ${token}`
316
+ return this
317
+ }
318
+
319
+ /** Pass HTTP Basic credentials as the `Authorization` header — japa `.basicAuth()`. */
320
+ basicAuth(user: string, password: string): this {
321
+ const encoded = Buffer.from(`${user}:${password}`, 'utf8').toString('base64')
322
+ this.#headers.authorization = `Basic ${encoded}`
323
+ return this
324
+ }
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
+
387
+ /**
388
+ * Satisfy blackhole's signed double-submit CSRF check: echo the `XSRF-TOKEN`
389
+ * cookie back in the `X-XSRF-TOKEN` header (the pair the server compares).
390
+ */
391
+ withCsrf(token?: string): this {
392
+ if (token !== undefined) this.#cookies[CSRF_COOKIE_NAME] = token
393
+ const value = this.#cookies[CSRF_COOKIE_NAME]
394
+ if (value === undefined) {
395
+ throw new Error(
396
+ `RequestBuilder: withCsrf() found no '${CSRF_COOKIE_NAME}' cookie. Set it first via ` +
397
+ `.cookie('${CSRF_COOKIE_NAME}', token) (extracted from a prior safe GET) or pass the token: withCsrf(token).`,
398
+ )
399
+ }
400
+ this.#headers[CSRF_HEADER_NAME] = value
401
+ return this
402
+ }
403
+
404
+ /** japa/api-client's spelling of {@link withCsrf}. */
405
+ withCsrfToken(token?: string): this {
406
+ return this.withCsrf(token)
407
+ }
408
+
162
409
  /**
163
410
  * Attach auth for a user. Uses the strategy passed to `client`'s `auth`
164
- * option — Warden's session/JWT/API-key strategy decides the header/cookie
165
- * shape. Without a strategy, throws at send time.
411
+ * option. Without a strategy, throws at send time.
166
412
  */
167
413
  withAuth(subject: AuthSubject): this {
168
414
  this.#pendingAuth = subject
@@ -175,82 +421,123 @@ export class RequestBuilder {
175
421
  return this
176
422
  }
177
423
 
178
- /** Fire the request (once) and return the raw response. Idempotent. */
179
- send(): Promise<TestResponse> {
424
+ /** Fire the request (once) and return the rich response. Idempotent. */
425
+ send(): Promise<ApiResponse> {
180
426
  if (this.#sent === null) this.#sent = this.#execute()
181
427
  return this.#sent
182
428
  }
183
429
 
184
- async expectStatus(code: number): Promise<this> {
185
- const res = await this.send()
186
- if (res.status !== code) {
187
- throw new ExpectationError(
188
- `Expected status ${code}, got ${res.status}. Body: ${capBody(res.body)}`,
189
- )
190
- }
191
- 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)
192
441
  }
193
442
 
194
- 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> {
195
445
  const res = await this.send()
196
- const actual = res.headers[name.toLowerCase()]
197
- if (actual === undefined) {
198
- throw new ExpectationError(
199
- `Expected header ${name}, not present in response headers: ${Object.keys(res.headers).join(', ')}`,
200
- )
201
- }
202
- if (value instanceof RegExp) {
203
- if (!value.test(actual)) {
204
- throw new ExpectationError(`Expected header ${name} to match ${value}, got "${actual}"`)
205
- }
206
- } else if (actual !== value) {
207
- throw new ExpectationError(`Expected header ${name} = "${value}", got "${actual}"`)
208
- }
446
+ for (const check of this.#checks) check(res)
447
+ return res
448
+ }
449
+
450
+ /** Register a lazy assertion; returns `this` for synchronous chaining. */
451
+ #assert(check: (res: ApiResponse) => void): this {
452
+ this.#checks.push(check)
209
453
  return this
210
454
  }
211
455
 
212
- async expectCookie(name: string, value?: string | RegExp): Promise<this> {
213
- const res = await this.send()
214
- const setCookie = res.headers['set-cookie']
215
- if (!setCookie) {
216
- throw new ExpectationError(`Expected cookie ${name}, but no Set-Cookie header was returned`)
217
- }
218
- const cookies = setCookie.split(/,(?=\s*\w+=)/)
219
- const match = cookies.find((c) => c.trimStart().startsWith(`${name}=`))
220
- if (!match) {
221
- throw new ExpectationError(`Expected cookie ${name}, not found in: ${setCookie}`)
222
- }
223
- if (value === undefined) return this
224
- const rawVal = match.split(';')[0]?.split('=')[1] ?? ''
225
- if (value instanceof RegExp) {
226
- if (!value.test(rawVal)) {
227
- throw new ExpectationError(`Expected cookie ${name} to match ${value}, got "${rawVal}"`)
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
+ )
228
475
  }
229
- } else if (rawVal !== value) {
230
- throw new ExpectationError(`Expected cookie ${name} = "${value}", got "${rawVal}"`)
231
- }
232
- return this
476
+ })
233
477
  }
234
478
 
235
- async expectJson(expected: unknown): Promise<this> {
236
- const res = await this.send()
237
- let actual: unknown
238
- try {
239
- actual = res.json()
240
- } catch (err) {
241
- throw new ExpectationError(
242
- `Expected JSON body, got non-JSON. Body: ${capBody(res.body)} (parse error: ${err instanceof Error ? err.message : String(err)})`,
243
- )
244
- }
245
- if (!partialMatch(actual, expected)) {
246
- throw new ExpectationError(
247
- `JSON partial match failed.\nExpected (partial): ${JSON.stringify(expected)}\nActual: ${capBody(JSON.stringify(actual))}`,
248
- )
249
- }
479
+ // ── Japa assertions (lazy — delegate to the response) ─────────────────────
480
+
481
+ assertStatus(code: number): this {
482
+ return this.#assert((res) => res.assertStatus(code))
483
+ }
484
+ assertHeader(name: string, value?: string | RegExp): this {
485
+ return this.#assert((res) => res.assertHeader(name, value))
486
+ }
487
+ assertHeaderMissing(name: string): this {
488
+ return this.#assert((res) => res.assertHeaderMissing(name))
489
+ }
490
+ assertCookie(name: string, value?: string | RegExp): this {
491
+ return this.#assert((res) => res.assertCookie(name, value))
492
+ }
493
+ assertCookieMissing(name: string): this {
494
+ return this.#assert((res) => res.assertCookieMissing(name))
495
+ }
496
+ assertBody(expected: unknown): this {
497
+ return this.#assert((res) => res.assertBody(expected))
498
+ }
499
+ assertBodyContains(subset: unknown): this {
500
+ return this.#assert((res) => res.assertBodyContains(subset))
501
+ }
502
+ assertBodyNotContains(subset: unknown): this {
503
+ return this.#assert((res) => res.assertBodyNotContains(subset))
504
+ }
505
+ assertTextIncludes(substring: string): this {
506
+ return this.#assert((res) => res.assertTextIncludes(substring))
507
+ }
508
+ assertRedirectsTo(path: string): this {
509
+ return this.#assert((res) => res.assertRedirectsTo(path))
510
+ }
511
+
512
+ // ── Request debugging (Japa `request.dump*` — dumps the REQUEST) ──────────
513
+
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
+ )
522
+ return this
523
+ }
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
528
+ }
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`)
532
+ return this
533
+ }
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`)
250
537
  return this
251
538
  }
252
539
 
253
- async #execute(): Promise<TestResponse> {
540
+ async #execute(): Promise<ApiResponse> {
254
541
  // Resolve auth before sending.
255
542
  if (this.#pendingAuth !== null) {
256
543
  if (!this.#authStrategy) {
@@ -277,7 +564,21 @@ export class RequestBuilder {
277
564
  // json()/form()/body(); encode them with a fresh boundary at send time.
278
565
  if (this.#multipart.length > 0) {
279
566
  const boundary = `----ReamRequestBuilder${crypto.randomUUID().replace(/-/g, '')}`
280
- 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)
281
582
  this.#headers['content-type'] = `multipart/form-data; boundary=${boundary}`
282
583
  }
283
584
 
@@ -287,15 +588,178 @@ export class RequestBuilder {
287
588
  this.#headers.cookie = cookieEntries.map(([k, v]) => `${k}=${v}`).join('; ')
288
589
  }
289
590
 
290
- return this.#sender(this.#method, this.#path, {
591
+ // Merge `.qs()` params into the path, preserving any existing query string.
592
+ let path = this.#path
593
+ const qs = this.#query.toString()
594
+ if (qs) {
595
+ path += (path.includes('?') ? '&' : '?') + qs
596
+ }
597
+
598
+ let method = this.#method
599
+ let body = this.#body
600
+ let raw = await this.#sender(method, path, {
291
601
  headers: this.#headers,
292
- body: this.#body,
602
+ body,
603
+ timeoutMs: this.#timeoutMs,
293
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)
294
684
  }
295
685
  }
296
686
 
297
- function capBody(s: string, max = 512): string {
298
- return s.length <= max ? s : `${s.slice(0, max)}...[truncated]`
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))
728
+ }
729
+ return Buffer.concat(chunks)
730
+ }
731
+
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
+ }
299
763
  }
300
764
 
301
765
  /** Strip CR/LF and escape quotes so a name/filename can't break the headers. */
@@ -303,19 +767,21 @@ function sanitizeHeaderParam(value: string): string {
303
767
  return value.replace(/[\r\n]/g, '').replace(/"/g, '%22')
304
768
  }
305
769
 
306
- /** Encode multipart/form-data parts (RFC 7578) into a single body Buffer. */
307
- 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 {
308
772
  const CRLF = '\r\n'
309
773
  const chunks: Buffer[] = []
310
774
  for (const part of parts) {
311
775
  if (part.kind === 'field') {
776
+ const valueBuf = part.value
312
777
  chunks.push(
313
778
  Buffer.from(
314
779
  `--${boundary}${CRLF}` +
315
- `Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"${CRLF}${CRLF}` +
316
- `${part.value}${CRLF}`,
780
+ `Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"${CRLF}${CRLF}`,
317
781
  'utf8',
318
782
  ),
783
+ valueBuf,
784
+ Buffer.from(CRLF, 'utf8'),
319
785
  )
320
786
  } else {
321
787
  chunks.push(
@@ -333,29 +799,3 @@ function encodeMultipart(parts: MultipartPart[], boundary: string): Buffer {
333
799
  chunks.push(Buffer.from(`--${boundary}--${CRLF}`, 'utf8'))
334
800
  return Buffer.concat(chunks)
335
801
  }
336
-
337
- /** Narrowing guard — a non-null, non-array object usable as a string map. */
338
- function isRecord(x: unknown): x is Record<string, unknown> {
339
- return typeof x === 'object' && x !== null && !Array.isArray(x)
340
- }
341
-
342
- /**
343
- * Partial deep-match. For objects, every key in `expected` must match in
344
- * `actual`. For arrays, every element in `expected` must match SOMEWHERE in
345
- * `actual` (order-independent). Primitives compared by strict equality.
346
- */
347
- export function partialMatch(actual: unknown, expected: unknown): boolean {
348
- if (expected === null || expected === undefined) {
349
- return actual === expected
350
- }
351
- if (typeof expected !== 'object') return actual === expected
352
- if (Array.isArray(expected)) {
353
- if (!Array.isArray(actual)) return false
354
- return expected.every((want) => actual.some((have) => partialMatch(have, want)))
355
- }
356
- if (!isRecord(actual) || !isRecord(expected)) return false
357
- for (const key of Object.keys(expected)) {
358
- if (!partialMatch(actual[key], expected[key])) return false
359
- }
360
- return true
361
- }