@c9up/ream 0.1.26 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/dist/Ignitor.d.ts +115 -7
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +233 -37
- package/dist/Ignitor.js.map +1 -1
- package/dist/console/BaseCommand.d.ts +217 -0
- package/dist/console/BaseCommand.d.ts.map +1 -0
- package/dist/console/BaseCommand.js +340 -0
- package/dist/console/BaseCommand.js.map +1 -0
- package/dist/console/Console.d.ts +86 -0
- package/dist/console/Console.d.ts.map +1 -0
- package/dist/console/Console.js +134 -0
- package/dist/console/Console.js.map +1 -0
- package/dist/console/ExceptionHandler.d.ts +28 -0
- package/dist/console/ExceptionHandler.d.ts.map +1 -0
- package/dist/console/ExceptionHandler.js +76 -0
- package/dist/console/ExceptionHandler.js.map +1 -0
- package/dist/console/HelpCommand.d.ts +16 -0
- package/dist/console/HelpCommand.d.ts.map +1 -0
- package/dist/console/HelpCommand.js +40 -0
- package/dist/console/HelpCommand.js.map +1 -0
- package/dist/console/IndexGenerator.d.ts +24 -0
- package/dist/console/IndexGenerator.d.ts.map +1 -0
- package/dist/console/IndexGenerator.js +65 -0
- package/dist/console/IndexGenerator.js.map +1 -0
- package/dist/console/Kernel.d.ts +298 -0
- package/dist/console/Kernel.d.ts.map +1 -0
- package/dist/console/Kernel.js +1120 -0
- package/dist/console/Kernel.js.map +1 -0
- package/dist/console/ListCommand.d.ts +18 -0
- package/dist/console/ListCommand.d.ts.map +1 -0
- package/dist/console/ListCommand.js +60 -0
- package/dist/console/ListCommand.js.map +1 -0
- package/dist/console/assertions.d.ts +20 -0
- package/dist/console/assertions.d.ts.map +1 -0
- package/dist/console/assertions.js +84 -0
- package/dist/console/assertions.js.map +1 -0
- package/dist/console/cliui.d.ts +248 -0
- package/dist/console/cliui.d.ts.map +1 -0
- package/dist/console/cliui.js +587 -0
- package/dist/console/cliui.js.map +1 -0
- package/dist/console/decorators.d.ts +90 -0
- package/dist/console/decorators.d.ts.map +1 -0
- package/dist/console/decorators.js +142 -0
- package/dist/console/decorators.js.map +1 -0
- package/dist/console/index.d.ts +32 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +24 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/loaders.d.ts +52 -0
- package/dist/console/loaders.d.ts.map +1 -0
- package/dist/console/loaders.js +131 -0
- package/dist/console/loaders.js.map +1 -0
- package/dist/console/parser.d.ts +86 -0
- package/dist/console/parser.d.ts.map +1 -0
- package/dist/console/parser.js +440 -0
- package/dist/console/parser.js.map +1 -0
- package/dist/console/prompts.d.ts +124 -0
- package/dist/console/prompts.d.ts.map +1 -0
- package/dist/console/prompts.js +364 -0
- package/dist/console/prompts.js.map +1 -0
- package/dist/console/tracing.d.ts +17 -0
- package/dist/console/tracing.d.ts.map +1 -0
- package/dist/console/tracing.js +11 -0
- package/dist/console/tracing.js.map +1 -0
- package/dist/console/types.d.ts +255 -0
- package/dist/console/types.d.ts.map +1 -0
- package/dist/console/types.js +25 -0
- package/dist/console/types.js.map +1 -0
- package/dist/console/ui.d.ts +20 -0
- package/dist/console/ui.d.ts.map +1 -0
- package/dist/console/ui.js +31 -0
- package/dist/console/ui.js.map +1 -0
- package/dist/console/utils.d.ts +27 -0
- package/dist/console/utils.d.ts.map +1 -0
- package/dist/console/utils.js +56 -0
- package/dist/console/utils.js.map +1 -0
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +40 -12
- package/dist/container/Container.js.map +1 -1
- package/dist/container/types.d.ts +14 -2
- package/dist/container/types.d.ts.map +1 -1
- package/dist/decorators/Service.d.ts +7 -0
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/decorators/Service.js +23 -3
- package/dist/decorators/Service.js.map +1 -1
- package/dist/errors/ReamError.d.ts +25 -0
- package/dist/errors/ReamError.d.ts.map +1 -1
- package/dist/errors/ReamError.js +5 -0
- package/dist/errors/ReamError.js.map +1 -1
- package/dist/errors/prettyPrintError.d.ts +9 -0
- package/dist/errors/prettyPrintError.d.ts.map +1 -0
- package/dist/errors/prettyPrintError.js +22 -0
- package/dist/errors/prettyPrintError.js.map +1 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts +29 -8
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +39 -1
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +13 -2
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/services/app.d.ts +1 -1
- package/dist/services/app.js +1 -1
- package/dist/services/console.d.ts +21 -0
- package/dist/services/console.d.ts.map +1 -0
- package/dist/services/console.js +35 -0
- package/dist/services/console.js.map +1 -0
- package/dist/testing/ApiResponse.d.ts +218 -0
- package/dist/testing/ApiResponse.d.ts.map +1 -0
- package/dist/testing/ApiResponse.js +576 -0
- package/dist/testing/ApiResponse.js.map +1 -0
- package/dist/testing/RequestBuilder.d.ts +132 -72
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +389 -222
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/testing/TestClient.d.ts +34 -39
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +48 -56
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.js +3 -3
- package/dist/utils/Macroable.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +15 -2
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Ignitor.ts +340 -42
- package/src/console/BaseCommand.ts +467 -0
- package/src/console/Console.ts +174 -0
- package/src/console/ExceptionHandler.ts +91 -0
- package/src/console/HelpCommand.ts +37 -0
- package/src/console/IndexGenerator.ts +79 -0
- package/src/console/Kernel.ts +1382 -0
- package/src/console/ListCommand.ts +55 -0
- package/src/console/assertions.ts +114 -0
- package/src/console/cliui.ts +756 -0
- package/src/console/decorators.ts +199 -0
- package/src/console/index.ts +67 -0
- package/src/console/loaders.ts +145 -0
- package/src/console/parser.ts +598 -0
- package/src/console/prompts.ts +556 -0
- package/src/console/tracing.ts +22 -0
- package/src/console/types.ts +280 -0
- package/src/console/ui.ts +37 -0
- package/src/console/utils.ts +69 -0
- package/src/container/Container.ts +55 -13
- package/src/container/types.ts +15 -2
- package/src/decorators/Service.ts +28 -3
- package/src/errors/ReamError.ts +31 -0
- package/src/errors/prettyPrintError.ts +20 -0
- package/src/index.ts +30 -4
- package/src/middleware/Pipeline.ts +76 -5
- package/src/rpc/RpcRouter.ts +16 -2
- package/src/services/app.ts +1 -1
- package/src/services/console.ts +42 -0
- package/src/testing/ApiResponse.ts +691 -0
- package/src/testing/RequestBuilder.ts +498 -275
- package/src/testing/TestClient.ts +63 -86
- package/src/utils/Macroable.ts +3 -3
- package/dist/console/CommandRunner.d.ts +0 -18
- package/dist/console/CommandRunner.d.ts.map +0 -1
- package/dist/console/CommandRunner.js +0 -65
- package/dist/console/CommandRunner.js.map +0 -1
- 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
|
-
* .
|
|
5
|
+
* .get('/api/users/42')
|
|
8
6
|
* .withAuth(user)
|
|
9
|
-
* .
|
|
10
|
-
* .
|
|
7
|
+
* .assertOk()
|
|
8
|
+
* .assertBody({ id: 42 })
|
|
11
9
|
*
|
|
12
|
-
* The builder chains: setters
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
/**
|
|
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:
|
|
75
|
-
| { kind: 'file'; name: string; filename: string; contentType: string; content:
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
127
|
-
|
|
128
|
-
|
|
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`
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* `Content-Type` header are produced at send
|
|
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:
|
|
142
|
-
|
|
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,
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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,
|
|
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
|
|
271
|
+
filename,
|
|
157
272
|
contentType: options.contentType ?? 'application/octet-stream',
|
|
158
|
-
content
|
|
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
|
|
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
|
|
242
|
-
send(): Promise<
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
return this.expectStatus(200)
|
|
481
|
+
assertStatus(code: number): this {
|
|
482
|
+
return this.#assert((res) => res.assertStatus(code))
|
|
323
483
|
}
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
return this
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
515
|
-
|
|
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:
|
|
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
|
-
}
|