@c9up/ream 0.1.26 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +356 -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 +548 -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,14 +1,51 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Fluent HTTP request builder with assertion methods (`@japa/api-client` model).
|
|
3
|
+
*
|
|
4
|
+
* await client
|
|
5
|
+
* .get('/api/users/42')
|
|
6
|
+
* .withAuth(user)
|
|
7
|
+
* .assertOk()
|
|
8
|
+
* .assertBody({ id: 42 })
|
|
9
|
+
*
|
|
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
|
+
*/
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { basename } from 'node:path';
|
|
18
|
+
import { ApiResponse, capBody, ExpectationError, partialMatch, STATUS_ASSERTIONS, } from './ApiResponse.js';
|
|
19
|
+
// Re-export the shared response types + matchers so `@c9up/ream/testing`
|
|
20
|
+
// consumers keep importing them from here (back-compat).
|
|
21
|
+
export { ApiResponse, deepEqual, partialMatch, } from './ApiResponse.js';
|
|
8
22
|
/** Cookie the signed CSRF token is issued in (blackhole default: `XSRF-TOKEN`). */
|
|
9
23
|
const CSRF_COOKIE_NAME = 'XSRF-TOKEN';
|
|
10
24
|
/** Header the client echoes the token back in (Axios/Angular default). */
|
|
11
25
|
const CSRF_HEADER_NAME = 'x-xsrf-token';
|
|
26
|
+
/** Content-type / accept shorthands — mirrors japa/api-client's `.type()`/`.accept()`. */
|
|
27
|
+
const TYPE_SHORTHANDS = {
|
|
28
|
+
json: 'application/json',
|
|
29
|
+
form: 'application/x-www-form-urlencoded',
|
|
30
|
+
urlencoded: 'application/x-www-form-urlencoded',
|
|
31
|
+
text: 'text/plain',
|
|
32
|
+
html: 'text/html',
|
|
33
|
+
xml: 'application/xml',
|
|
34
|
+
};
|
|
35
|
+
/** Expand a shorthand (`'json'`) to a full MIME type, or pass a full type through. */
|
|
36
|
+
function resolveMime(type) {
|
|
37
|
+
return TYPE_SHORTHANDS[type] ?? type;
|
|
38
|
+
}
|
|
39
|
+
/** HTTP statuses that trigger redirect-following (Japa `.redirects()`). */
|
|
40
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
41
|
+
/** Japa follows 5 redirects by default. */
|
|
42
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
43
|
+
/** Consumer-registered macros/getters (Japa `ApiRequest.macro`/`.getter`). */
|
|
44
|
+
const requestMacros = new Map();
|
|
45
|
+
const requestGetters = new Map();
|
|
46
|
+
/** Consumer-registered request body serializers (Japa `ApiRequest.addSerializer`), by name. */
|
|
47
|
+
const requestSerializers = new Map();
|
|
48
|
+
// 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.
|
|
12
49
|
export class RequestBuilder {
|
|
13
50
|
#sender;
|
|
14
51
|
#method;
|
|
@@ -20,12 +57,19 @@ export class RequestBuilder {
|
|
|
20
57
|
#query = new URLSearchParams();
|
|
21
58
|
#authStrategy;
|
|
22
59
|
#pendingAuth = null;
|
|
60
|
+
#timeoutMs;
|
|
61
|
+
#maxRedirects = DEFAULT_MAX_REDIRECTS;
|
|
23
62
|
#sent = null;
|
|
63
|
+
// Lazy assertions (japa model): each `assert*`/`expect*` registers a check and
|
|
64
|
+
// returns `this` synchronously; the checks run in order when the builder is
|
|
65
|
+
// awaited (`then`) — after the single send.
|
|
66
|
+
#checks = [];
|
|
24
67
|
constructor(sender, method, path, authStrategy = null) {
|
|
25
68
|
this.#sender = sender;
|
|
26
69
|
this.#method = method;
|
|
27
70
|
this.#path = path;
|
|
28
71
|
this.#authStrategy = authStrategy;
|
|
72
|
+
applyRequestExtensions(this);
|
|
29
73
|
}
|
|
30
74
|
headers(map) {
|
|
31
75
|
for (const [k, v] of Object.entries(map)) {
|
|
@@ -34,7 +78,8 @@ export class RequestBuilder {
|
|
|
34
78
|
return this;
|
|
35
79
|
}
|
|
36
80
|
header(name, value) {
|
|
37
|
-
|
|
81
|
+
// Japa accepts string | string[]; a list is joined into one header value.
|
|
82
|
+
this.#headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
|
|
38
83
|
return this;
|
|
39
84
|
}
|
|
40
85
|
json(data) {
|
|
@@ -42,6 +87,10 @@ export class RequestBuilder {
|
|
|
42
87
|
this.#headers['content-type'] = 'application/json';
|
|
43
88
|
return this;
|
|
44
89
|
}
|
|
90
|
+
/** Japa `.unsafeJson()` — set a JSON body without any transform (alias of {@link json}). */
|
|
91
|
+
unsafeJson(data) {
|
|
92
|
+
return this.json(data);
|
|
93
|
+
}
|
|
45
94
|
body(data, contentType) {
|
|
46
95
|
this.#body = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
|
47
96
|
if (contentType)
|
|
@@ -49,34 +98,64 @@ export class RequestBuilder {
|
|
|
49
98
|
return this;
|
|
50
99
|
}
|
|
51
100
|
form(data) {
|
|
52
|
-
|
|
101
|
+
// Array values repeat the key (`a=1&a=2`) — Japa parity.
|
|
102
|
+
const pairs = [];
|
|
103
|
+
for (const [k, v] of Object.entries(data)) {
|
|
104
|
+
const values = Array.isArray(v) ? v : [v];
|
|
105
|
+
for (const item of values) {
|
|
106
|
+
pairs.push(`${encodeURIComponent(k)}=${encodeURIComponent(fieldToString(item))}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
53
109
|
this.#body = Buffer.from(pairs.join('&'), 'utf8');
|
|
54
110
|
this.#headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
55
111
|
return this;
|
|
56
112
|
}
|
|
113
|
+
/** Japa `.unsafeForm()` — set a urlencoded body without transform (alias of {@link form}). */
|
|
114
|
+
unsafeForm(data) {
|
|
115
|
+
return this.form(data);
|
|
116
|
+
}
|
|
57
117
|
/**
|
|
58
|
-
* Add a `multipart/form-data`
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* `Content-Type` header are produced at send
|
|
118
|
+
* Add a `multipart/form-data` field — mirrors Adonis/japa's `.field()`. Accepts
|
|
119
|
+
* a string/number/boolean, a Buffer, a Blob, a Readable stream, or an array of
|
|
120
|
+
* these (repeated part). Calling `.field()`/`.fields()`/`.file()` switches the
|
|
121
|
+
* body to multipart; the boundary + `Content-Type` header are produced at send
|
|
122
|
+
* time (streams/blobs are drained to Buffers then).
|
|
62
123
|
*/
|
|
63
124
|
field(name, value) {
|
|
64
|
-
|
|
125
|
+
const values = Array.isArray(value) ? value : [value];
|
|
126
|
+
for (const v of values) {
|
|
127
|
+
this.#multipart.push({
|
|
128
|
+
kind: 'field',
|
|
129
|
+
name,
|
|
130
|
+
value: isScalar(v) ? fieldToString(v) : v,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
/** Add several multipart fields at once — Japa `.fields()`. */
|
|
136
|
+
fields(map) {
|
|
137
|
+
for (const [name, value] of Object.entries(map)) {
|
|
138
|
+
this.field(name, value);
|
|
139
|
+
}
|
|
65
140
|
return this;
|
|
66
141
|
}
|
|
67
142
|
/**
|
|
68
143
|
* Attach a file as a `multipart/form-data` part — mirrors Adonis/japa's
|
|
69
|
-
* `.file(field,
|
|
70
|
-
*
|
|
71
|
-
*
|
|
144
|
+
* `.file(field, value, { filename, contentType })`. `value` is a Buffer, a
|
|
145
|
+
* Blob, a Readable stream, OR a string absolute PATH to a file on disk (read at
|
|
146
|
+
* call time, Japa parity). The filename defaults to the path basename / field
|
|
147
|
+
* name; streams/blobs are drained to Buffers at send time.
|
|
72
148
|
*/
|
|
73
|
-
file(field,
|
|
149
|
+
file(field, value, options = {}) {
|
|
150
|
+
// A string is a PATH → read now; other kinds are resolved at send time.
|
|
151
|
+
const content = typeof value === 'string' ? readFileSync(value) : value;
|
|
152
|
+
const filename = options.filename ?? (typeof value === 'string' ? basename(value) : field);
|
|
74
153
|
this.#multipart.push({
|
|
75
154
|
kind: 'file',
|
|
76
155
|
name: field,
|
|
77
|
-
filename
|
|
156
|
+
filename,
|
|
78
157
|
contentType: options.contentType ?? 'application/octet-stream',
|
|
79
|
-
content
|
|
158
|
+
content,
|
|
80
159
|
});
|
|
81
160
|
return this;
|
|
82
161
|
}
|
|
@@ -105,6 +184,14 @@ export class RequestBuilder {
|
|
|
105
184
|
}
|
|
106
185
|
return this;
|
|
107
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Append query params WITHOUT any validation/normalisation — Japa
|
|
189
|
+
* `.unsafeQs({ … })`. Values are still URL-encoded so the wire stays valid, but
|
|
190
|
+
* (unlike `.qs()`) no key filtering is applied; arrays repeat the key.
|
|
191
|
+
*/
|
|
192
|
+
unsafeQs(params) {
|
|
193
|
+
return this.qs(params);
|
|
194
|
+
}
|
|
108
195
|
/** Pass a bearer token as the `Authorization` header — japa `.bearerToken()`. */
|
|
109
196
|
bearerToken(token) {
|
|
110
197
|
this.#headers.authorization = `Bearer ${token}`;
|
|
@@ -116,15 +203,65 @@ export class RequestBuilder {
|
|
|
116
203
|
this.#headers.authorization = `Basic ${encoded}`;
|
|
117
204
|
return this;
|
|
118
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Set the request `Content-Type` from a shorthand (`'json'`, `'form'`, …) or a
|
|
208
|
+
* full MIME type — mirrors japa/api-client's `.type()`.
|
|
209
|
+
*/
|
|
210
|
+
type(mime) {
|
|
211
|
+
this.#headers['content-type'] = resolveMime(mime);
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Set the `Accept` header from a shorthand (`'json'`, `'html'`, …) or a full
|
|
216
|
+
* MIME type — mirrors japa/api-client's `.accept()`.
|
|
217
|
+
*/
|
|
218
|
+
accept(mime) {
|
|
219
|
+
this.#headers.accept = resolveMime(mime);
|
|
220
|
+
return this;
|
|
221
|
+
}
|
|
222
|
+
/** Per-request timeout in ms — Japa `.timeout()`. Honoured by the sender. */
|
|
223
|
+
timeout(ms) {
|
|
224
|
+
this.#timeoutMs = ms;
|
|
225
|
+
return this;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Follow up to `count` redirects before resolving — Japa `.redirects()`.
|
|
229
|
+
* Defaults to 5 (Japa default); pass `0` to disable following.
|
|
230
|
+
*/
|
|
231
|
+
redirects(count) {
|
|
232
|
+
this.#maxRedirects = Math.max(0, count);
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
// TLS knobs — Japa parity. The test server is plain HTTP over loopback, so
|
|
236
|
+
// these are inert (there is no TLS handshake to configure); they exist so
|
|
237
|
+
// Japa-shaped code type-checks and runs unchanged. NAMED as a ream deviation.
|
|
238
|
+
/** No-op: the loopback test server has no TLS — Japa `.trustLocalhost()`. */
|
|
239
|
+
trustLocalhost(_trust = true) {
|
|
240
|
+
return this;
|
|
241
|
+
}
|
|
242
|
+
/** No-op: no TLS on the loopback test server — Japa `.ca()`. */
|
|
243
|
+
ca(_cert) {
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
/** No-op: no TLS on the loopback test server — Japa `.cert()`. */
|
|
247
|
+
cert(_chain) {
|
|
248
|
+
return this;
|
|
249
|
+
}
|
|
250
|
+
/** No-op: no TLS on the loopback test server — Japa `.privateKey()`. */
|
|
251
|
+
privateKey(_key) {
|
|
252
|
+
return this;
|
|
253
|
+
}
|
|
254
|
+
/** No-op: no TLS on the loopback test server — Japa `.pfx()`. */
|
|
255
|
+
pfx(_encoded) {
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
/** No-op: no TLS on the loopback test server — Japa `.disableTLSCerts()`. */
|
|
259
|
+
disableTLSCerts() {
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
119
262
|
/**
|
|
120
263
|
* Satisfy blackhole's signed double-submit CSRF check: echo the `XSRF-TOKEN`
|
|
121
264
|
* cookie back in the `X-XSRF-TOKEN` header (the pair the server compares).
|
|
122
|
-
*
|
|
123
|
-
* With a `token` argument, sets both the cookie and the header — useful when
|
|
124
|
-
* you already hold a signed token. Without one, reads the token from a cookie
|
|
125
|
-
* set earlier on this request (typically extracted from a prior safe GET's
|
|
126
|
-
* `Set-Cookie`); throws if none is present so the mistake is caught at the
|
|
127
|
-
* call site instead of surfacing as a confusing 403.
|
|
128
265
|
*/
|
|
129
266
|
withCsrf(token) {
|
|
130
267
|
if (token !== undefined)
|
|
@@ -137,10 +274,13 @@ export class RequestBuilder {
|
|
|
137
274
|
this.#headers[CSRF_HEADER_NAME] = value;
|
|
138
275
|
return this;
|
|
139
276
|
}
|
|
277
|
+
/** japa/api-client's spelling of {@link withCsrf}. */
|
|
278
|
+
withCsrfToken(token) {
|
|
279
|
+
return this.withCsrf(token);
|
|
280
|
+
}
|
|
140
281
|
/**
|
|
141
282
|
* Attach auth for a user. Uses the strategy passed to `client`'s `auth`
|
|
142
|
-
* option
|
|
143
|
-
* shape. Without a strategy, throws at send time.
|
|
283
|
+
* option. Without a strategy, throws at send time.
|
|
144
284
|
*/
|
|
145
285
|
withAuth(subject) {
|
|
146
286
|
this.#pendingAuth = subject;
|
|
@@ -151,191 +291,107 @@ export class RequestBuilder {
|
|
|
151
291
|
this.#pendingAuth = { id: userId };
|
|
152
292
|
return this;
|
|
153
293
|
}
|
|
154
|
-
/** Fire the request (once) and return the
|
|
294
|
+
/** Fire the request (once) and return the rich response. Idempotent. */
|
|
155
295
|
send() {
|
|
156
296
|
if (this.#sent === null)
|
|
157
297
|
this.#sent = this.#execute();
|
|
158
298
|
return this.#sent;
|
|
159
299
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Thenable — `await client.get('/x')` sends the request and resolves to the
|
|
302
|
+
* {@link ApiResponse} (Japa/api-client parity). Assertion methods return `this`;
|
|
303
|
+
* awaiting one resolves via this `then` to the response after running the checks.
|
|
304
|
+
*/
|
|
305
|
+
// 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.
|
|
306
|
+
then(onFulfilled, onRejected) {
|
|
307
|
+
return this.#settle().then(onFulfilled, onRejected);
|
|
166
308
|
}
|
|
167
|
-
|
|
309
|
+
/** Send once, then run every registered assertion (throws on first failure). */
|
|
310
|
+
async #settle() {
|
|
168
311
|
const res = await this.send();
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
else if (actual !== value) {
|
|
179
|
-
throw new ExpectationError(`Expected header ${name} = "${value}", got "${actual}"`);
|
|
180
|
-
}
|
|
312
|
+
for (const check of this.#checks)
|
|
313
|
+
check(res);
|
|
314
|
+
return res;
|
|
315
|
+
}
|
|
316
|
+
/** Register a lazy assertion; returns `this` for synchronous chaining. */
|
|
317
|
+
#assert(check) {
|
|
318
|
+
this.#checks.push(check);
|
|
181
319
|
return this;
|
|
182
320
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
throw new ExpectationError(`Expected cookie ${name} to match ${value}, got "${rawVal}"`);
|
|
321
|
+
// ── ream-flavoured `expect*` aliases (kept for back-compat) ───────────────
|
|
322
|
+
expectStatus(code) {
|
|
323
|
+
return this.#assert((res) => res.assertStatus(code));
|
|
324
|
+
}
|
|
325
|
+
expectHeader(name, value) {
|
|
326
|
+
return this.#assert((res) => res.assertHeader(name, value));
|
|
327
|
+
}
|
|
328
|
+
expectCookie(name, value) {
|
|
329
|
+
return this.#assert((res) => res.assertCookie(name, value));
|
|
330
|
+
}
|
|
331
|
+
expectJson(expected) {
|
|
332
|
+
// ream alias: partial JSON match with its own message (kept for back-compat;
|
|
333
|
+
// `assertBodyContains` is the Japa-named equivalent).
|
|
334
|
+
return this.#assert((res) => {
|
|
335
|
+
if (!partialMatch(res.json(), expected)) {
|
|
336
|
+
throw new ExpectationError(`JSON partial match failed.\nExpected (partial): ${JSON.stringify(expected)}\nActual: ${capBody(JSON.stringify(res.json()))}`);
|
|
200
337
|
}
|
|
201
|
-
}
|
|
202
|
-
else if (rawVal !== value) {
|
|
203
|
-
throw new ExpectationError(`Expected cookie ${name} = "${value}", got "${rawVal}"`);
|
|
204
|
-
}
|
|
205
|
-
return this;
|
|
338
|
+
});
|
|
206
339
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
actual = res.json();
|
|
212
|
-
}
|
|
213
|
-
catch (err) {
|
|
214
|
-
throw new ExpectationError(`Expected JSON body, got non-JSON. Body: ${capBody(res.body)} (parse error: ${err instanceof Error ? err.message : String(err)})`);
|
|
215
|
-
}
|
|
216
|
-
if (!partialMatch(actual, expected)) {
|
|
217
|
-
throw new ExpectationError(`JSON partial match failed.\nExpected (partial): ${JSON.stringify(expected)}\nActual: ${capBody(JSON.stringify(actual))}`);
|
|
218
|
-
}
|
|
219
|
-
return this;
|
|
340
|
+
// ── Japa assertions (lazy — delegate to the response) ─────────────────────
|
|
341
|
+
assertStatus(code) {
|
|
342
|
+
return this.#assert((res) => res.assertStatus(code));
|
|
220
343
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// @japa/api-client's assertOk()/assertCreated()/… surface.
|
|
224
|
-
/** Assert a 200 OK response. */
|
|
225
|
-
assertOk() {
|
|
226
|
-
return this.expectStatus(200);
|
|
344
|
+
assertHeader(name, value) {
|
|
345
|
+
return this.#assert((res) => res.assertHeader(name, value));
|
|
227
346
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return this.expectStatus(201);
|
|
347
|
+
assertHeaderMissing(name) {
|
|
348
|
+
return this.#assert((res) => res.assertHeaderMissing(name));
|
|
231
349
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return this.expectStatus(204);
|
|
350
|
+
assertCookie(name, value) {
|
|
351
|
+
return this.#assert((res) => res.assertCookie(name, value));
|
|
235
352
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return this.expectStatus(400);
|
|
353
|
+
assertCookieMissing(name) {
|
|
354
|
+
return this.#assert((res) => res.assertCookieMissing(name));
|
|
239
355
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return this.expectStatus(401);
|
|
356
|
+
assertBody(expected) {
|
|
357
|
+
return this.#assert((res) => res.assertBody(expected));
|
|
243
358
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return this.expectStatus(403);
|
|
359
|
+
assertBodyContains(subset) {
|
|
360
|
+
return this.#assert((res) => res.assertBodyContains(subset));
|
|
247
361
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return this.expectStatus(404);
|
|
362
|
+
assertBodyNotContains(subset) {
|
|
363
|
+
return this.#assert((res) => res.assertBodyNotContains(subset));
|
|
251
364
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
* pathname (query/host ignored), mirroring japa's `assertRedirectsTo`.
|
|
255
|
-
*/
|
|
256
|
-
async assertRedirectsTo(path) {
|
|
257
|
-
const res = await this.send();
|
|
258
|
-
if (res.status < 300 || res.status >= 400) {
|
|
259
|
-
throw new ExpectationError(`Expected a redirect (3xx) to "${path}", got status ${res.status}.`);
|
|
260
|
-
}
|
|
261
|
-
const location = res.headers.location;
|
|
262
|
-
if (location === undefined) {
|
|
263
|
-
throw new ExpectationError(`Expected a redirect to "${path}", but the response has no Location header.`);
|
|
264
|
-
}
|
|
265
|
-
// Location may be absolute or path-only; compare pathnames either way.
|
|
266
|
-
const actual = location.startsWith('http')
|
|
267
|
-
? new URL(location).pathname
|
|
268
|
-
: (location.split('?')[0] ?? location);
|
|
269
|
-
if (actual !== path) {
|
|
270
|
-
throw new ExpectationError(`Expected redirect to "${path}", got "${actual}".`);
|
|
271
|
-
}
|
|
272
|
-
return this;
|
|
365
|
+
assertTextIncludes(substring) {
|
|
366
|
+
return this.#assert((res) => res.assertTextIncludes(substring));
|
|
273
367
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const actual = await this.#bodyJson();
|
|
277
|
-
if (!partialMatch(actual, subset)) {
|
|
278
|
-
throw new ExpectationError(`Expected body to contain subset.\nSubset: ${JSON.stringify(subset)}\nActual: ${capBody(JSON.stringify(actual))}`);
|
|
279
|
-
}
|
|
280
|
-
return this;
|
|
368
|
+
assertRedirectsTo(path) {
|
|
369
|
+
return this.#assert((res) => res.assertRedirectsTo(path));
|
|
281
370
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
371
|
+
// ── Request debugging (Japa `request.dump*` — dumps the REQUEST) ──────────
|
|
372
|
+
/** Print the pending request (method/path/headers/body) — Japa `request.dump()`. */
|
|
373
|
+
dump() {
|
|
374
|
+
process.stderr.write(`[helix:request.dump] ${this.#method} ${this.#path}\n` +
|
|
375
|
+
`headers: ${JSON.stringify(this.#headers, null, 2)}\n` +
|
|
376
|
+
`cookies: ${JSON.stringify(this.#cookies, null, 2)}\n` +
|
|
377
|
+
`body: ${capBody(this.#body.toString('utf8'), 2048)}\n`);
|
|
288
378
|
return this;
|
|
289
379
|
}
|
|
290
|
-
/**
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
*/
|
|
294
|
-
async assertHeader(name, value) {
|
|
295
|
-
if (value !== undefined)
|
|
296
|
-
return this.expectHeader(name, value);
|
|
297
|
-
const res = await this.send();
|
|
298
|
-
if (res.headers[name.toLowerCase()] === undefined) {
|
|
299
|
-
throw new ExpectationError(`Expected header ${name}, not present in: ${Object.keys(res.headers).join(', ')}`);
|
|
300
|
-
}
|
|
380
|
+
/** Print only the pending request body — Japa `request.dumpBody()`. */
|
|
381
|
+
dumpBody() {
|
|
382
|
+
process.stderr.write(`[helix:request.dumpBody] ${capBody(this.#body.toString('utf8'), 2048)}\n`);
|
|
301
383
|
return this;
|
|
302
384
|
}
|
|
303
|
-
/**
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (res.headers[name.toLowerCase()] !== undefined) {
|
|
307
|
-
throw new ExpectationError(`Expected header ${name} to be absent, but it was present.`);
|
|
308
|
-
}
|
|
385
|
+
/** Print only the pending request headers — Japa `request.dumpHeaders()`. */
|
|
386
|
+
dumpHeaders() {
|
|
387
|
+
process.stderr.write(`[helix:request.dumpHeaders] ${JSON.stringify(this.#headers, null, 2)}\n`);
|
|
309
388
|
return this;
|
|
310
389
|
}
|
|
311
|
-
/**
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
/** Assert the response did NOT set a cookie — japa `assertCookieMissing`. */
|
|
316
|
-
async assertCookieMissing(name) {
|
|
317
|
-
const res = await this.send();
|
|
318
|
-
const setCookie = res.headers['set-cookie'];
|
|
319
|
-
if (setCookie) {
|
|
320
|
-
const present = setCookie
|
|
321
|
-
.split(/,(?=\s*\w+=)/)
|
|
322
|
-
.some((c) => c.trimStart().startsWith(`${name}=`));
|
|
323
|
-
if (present) {
|
|
324
|
-
throw new ExpectationError(`Expected cookie ${name} to be absent, but it was set: ${setCookie}`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
390
|
+
/** Print only the pending request cookies — Japa `request.dumpCookies()`. */
|
|
391
|
+
dumpCookies() {
|
|
392
|
+
process.stderr.write(`[helix:request.dumpCookies] ${JSON.stringify(this.#cookies, null, 2)}\n`);
|
|
327
393
|
return this;
|
|
328
394
|
}
|
|
329
|
-
/** Send (once) and parse the body as JSON, wrapping parse errors as ExpectationError. */
|
|
330
|
-
async #bodyJson() {
|
|
331
|
-
const res = await this.send();
|
|
332
|
-
try {
|
|
333
|
-
return res.json();
|
|
334
|
-
}
|
|
335
|
-
catch (err) {
|
|
336
|
-
throw new ExpectationError(`Expected JSON body, got non-JSON. Body: ${capBody(res.body)} (parse error: ${err instanceof Error ? err.message : String(err)})`);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
395
|
async #execute() {
|
|
340
396
|
// Resolve auth before sending.
|
|
341
397
|
if (this.#pendingAuth !== null) {
|
|
@@ -360,7 +416,17 @@ export class RequestBuilder {
|
|
|
360
416
|
// json()/form()/body(); encode them with a fresh boundary at send time.
|
|
361
417
|
if (this.#multipart.length > 0) {
|
|
362
418
|
const boundary = `----ReamRequestBuilder${crypto.randomUUID().replace(/-/g, '')}`;
|
|
363
|
-
|
|
419
|
+
// Drain any stream/blob part to a Buffer before encoding (Japa parity).
|
|
420
|
+
const resolved = await Promise.all(this.#multipart.map(async (p) => p.kind === 'field'
|
|
421
|
+
? { kind: 'field', name: p.name, value: await toBuffer(p.value) }
|
|
422
|
+
: {
|
|
423
|
+
kind: 'file',
|
|
424
|
+
name: p.name,
|
|
425
|
+
filename: p.filename,
|
|
426
|
+
contentType: p.contentType,
|
|
427
|
+
content: await toBuffer(p.content),
|
|
428
|
+
}));
|
|
429
|
+
this.#body = encodeMultipart(resolved, boundary);
|
|
364
430
|
this.#headers['content-type'] = `multipart/form-data; boundary=${boundary}`;
|
|
365
431
|
}
|
|
366
432
|
// Serialise cookies into a single `Cookie:` header.
|
|
@@ -368,35 +434,164 @@ export class RequestBuilder {
|
|
|
368
434
|
if (cookieEntries.length > 0) {
|
|
369
435
|
this.#headers.cookie = cookieEntries.map(([k, v]) => `${k}=${v}`).join('; ');
|
|
370
436
|
}
|
|
371
|
-
// Merge `.qs()` params into the path, preserving any query string
|
|
372
|
-
// already carries.
|
|
437
|
+
// Merge `.qs()` params into the path, preserving any existing query string.
|
|
373
438
|
let path = this.#path;
|
|
374
439
|
const qs = this.#query.toString();
|
|
375
440
|
if (qs) {
|
|
376
441
|
path += (path.includes('?') ? '&' : '?') + qs;
|
|
377
442
|
}
|
|
378
|
-
|
|
443
|
+
let method = this.#method;
|
|
444
|
+
let body = this.#body;
|
|
445
|
+
let raw = await this.#sender(method, path, {
|
|
379
446
|
headers: this.#headers,
|
|
380
|
-
body
|
|
447
|
+
body,
|
|
448
|
+
timeoutMs: this.#timeoutMs,
|
|
381
449
|
});
|
|
450
|
+
// Follow redirects up to the configured budget — Japa `.redirects()` (5 by default).
|
|
451
|
+
const chain = [];
|
|
452
|
+
let followed = 0;
|
|
453
|
+
while (followed < this.#maxRedirects &&
|
|
454
|
+
REDIRECT_STATUSES.has(raw.status) &&
|
|
455
|
+
raw.headers.location !== undefined) {
|
|
456
|
+
const location = raw.headers.location;
|
|
457
|
+
chain.push(location);
|
|
458
|
+
followed += 1;
|
|
459
|
+
path = location.startsWith('http')
|
|
460
|
+
? new URL(location).pathname + new URL(location).search
|
|
461
|
+
: location;
|
|
462
|
+
// 303 (and, per browser convention, 301/302) switch to GET with no body;
|
|
463
|
+
// 307/308 preserve the method and body.
|
|
464
|
+
if (raw.status === 307 || raw.status === 308) {
|
|
465
|
+
// keep method + body
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
method = 'GET';
|
|
469
|
+
body = Buffer.alloc(0);
|
|
470
|
+
delete this.#headers['content-type'];
|
|
471
|
+
}
|
|
472
|
+
raw = await this.#sender(method, path, {
|
|
473
|
+
headers: this.#headers,
|
|
474
|
+
body,
|
|
475
|
+
timeoutMs: this.#timeoutMs,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return new ApiResponse(raw, { method, redirects: chain });
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Serialize `data` with a registered serializer and set it as the request body
|
|
482
|
+
* — the send-side twin of {@link ApiResponse.addParser}. Register serializers
|
|
483
|
+
* via {@link RequestBuilder.addSerializer} (Japa `ApiRequest.addSerializer`).
|
|
484
|
+
*/
|
|
485
|
+
serialize(name, data) {
|
|
486
|
+
const serializer = requestSerializers.get(name);
|
|
487
|
+
if (!serializer) {
|
|
488
|
+
throw new Error(`RequestBuilder: no serializer registered for "${name}" (see RequestBuilder.addSerializer).`);
|
|
489
|
+
}
|
|
490
|
+
const { body, contentType } = serializer(data);
|
|
491
|
+
this.#body = body;
|
|
492
|
+
this.#headers['content-type'] = contentType;
|
|
493
|
+
return this;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Register a shared property (Japa `ApiRequest.macro`) or lazy getter
|
|
497
|
+
* (`ApiRequest.getter`). The callback is invoked with `this` bound to the
|
|
498
|
+
* builder AND the builder as its first arg, so both Japa's
|
|
499
|
+
* `function () { return this.header('x') }` and `(req) => …` work.
|
|
500
|
+
*/
|
|
501
|
+
static macro(name, value) {
|
|
502
|
+
requestMacros.set(name, value);
|
|
382
503
|
}
|
|
504
|
+
static getter(name, fn) {
|
|
505
|
+
requestGetters.set(name, fn);
|
|
506
|
+
}
|
|
507
|
+
/** Register a request body serializer — Japa `ApiRequest.addSerializer`. */
|
|
508
|
+
static addSerializer(name, fn) {
|
|
509
|
+
requestSerializers.set(name, fn);
|
|
510
|
+
}
|
|
511
|
+
/** Register a response body parser — Japa `ApiRequest.addParser` (delegates to ApiResponse). */
|
|
512
|
+
static addParser(contentType, fn) {
|
|
513
|
+
ApiResponse.addParser(contentType, fn);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Attach the lazy status-shortcut assertions from the shared map so the builder
|
|
517
|
+
// and ApiResponse never disagree on the code set. Each defers to the public
|
|
518
|
+
// `expectStatus` (private fields aren't reachable from prototype-added fns).
|
|
519
|
+
for (const [method, code] of Object.entries(STATUS_ASSERTIONS)) {
|
|
520
|
+
Object.defineProperty(RequestBuilder.prototype, method, {
|
|
521
|
+
value() {
|
|
522
|
+
return this.expectStatus(code);
|
|
523
|
+
},
|
|
524
|
+
enumerable: false,
|
|
525
|
+
configurable: true,
|
|
526
|
+
writable: true,
|
|
527
|
+
});
|
|
383
528
|
}
|
|
384
|
-
|
|
385
|
-
|
|
529
|
+
/** Coerce a field value to its wire string (Buffers are handled separately). */
|
|
530
|
+
function fieldToString(value) {
|
|
531
|
+
return Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
|
|
532
|
+
}
|
|
533
|
+
/** Whether a multipart value is a scalar (string/number/boolean/Buffer), not a stream/blob. */
|
|
534
|
+
function isScalar(v) {
|
|
535
|
+
return (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Buffer.isBuffer(v));
|
|
536
|
+
}
|
|
537
|
+
/** Drain a multipart value (string/Buffer/Blob/Readable) to a Buffer at send time. */
|
|
538
|
+
async function toBuffer(v) {
|
|
539
|
+
if (Buffer.isBuffer(v))
|
|
540
|
+
return v;
|
|
541
|
+
if (typeof v === 'string')
|
|
542
|
+
return Buffer.from(v, 'utf8');
|
|
543
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
544
|
+
return Buffer.from(String(v), 'utf8');
|
|
545
|
+
// `in` narrows the Blob | Readable union without a cast: only Blob has arrayBuffer.
|
|
546
|
+
if ('arrayBuffer' in v)
|
|
547
|
+
return Buffer.from(await v.arrayBuffer());
|
|
548
|
+
// Readable / async-iterable → collect chunks.
|
|
549
|
+
const chunks = [];
|
|
550
|
+
for await (const chunk of v) {
|
|
551
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
552
|
+
}
|
|
553
|
+
return Buffer.concat(chunks);
|
|
554
|
+
}
|
|
555
|
+
/** Apply registered request macros/getters onto a freshly built builder. */
|
|
556
|
+
function applyRequestExtensions(req) {
|
|
557
|
+
for (const [name, value] of requestMacros) {
|
|
558
|
+
Object.defineProperty(req, name, {
|
|
559
|
+
value,
|
|
560
|
+
enumerable: true,
|
|
561
|
+
configurable: true,
|
|
562
|
+
writable: true,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
for (const [name, fn] of requestGetters) {
|
|
566
|
+
let computed = false;
|
|
567
|
+
let cached;
|
|
568
|
+
Object.defineProperty(req, name, {
|
|
569
|
+
enumerable: true,
|
|
570
|
+
configurable: true,
|
|
571
|
+
get() {
|
|
572
|
+
if (!computed) {
|
|
573
|
+
// `this`-bound AND passed as arg → both `function(){this.x}` and `(req)=>req.x` work.
|
|
574
|
+
cached = fn.call(req, req);
|
|
575
|
+
computed = true;
|
|
576
|
+
}
|
|
577
|
+
return cached;
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
}
|
|
386
581
|
}
|
|
387
582
|
/** Strip CR/LF and escape quotes so a name/filename can't break the headers. */
|
|
388
583
|
function sanitizeHeaderParam(value) {
|
|
389
584
|
return value.replace(/[\r\n]/g, '').replace(/"/g, '%22');
|
|
390
585
|
}
|
|
391
|
-
/** Encode multipart/form-data parts (RFC 7578) into a single body Buffer. */
|
|
586
|
+
/** Encode resolved multipart/form-data parts (RFC 7578) into a single body Buffer. */
|
|
392
587
|
function encodeMultipart(parts, boundary) {
|
|
393
588
|
const CRLF = '\r\n';
|
|
394
589
|
const chunks = [];
|
|
395
590
|
for (const part of parts) {
|
|
396
591
|
if (part.kind === 'field') {
|
|
592
|
+
const valueBuf = part.value;
|
|
397
593
|
chunks.push(Buffer.from(`--${boundary}${CRLF}` +
|
|
398
|
-
`Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"${CRLF}${CRLF}
|
|
399
|
-
`${part.value}${CRLF}`, 'utf8'));
|
|
594
|
+
`Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"${CRLF}${CRLF}`, 'utf8'), valueBuf, Buffer.from(CRLF, 'utf8'));
|
|
400
595
|
}
|
|
401
596
|
else {
|
|
402
597
|
chunks.push(Buffer.from(`--${boundary}${CRLF}` +
|
|
@@ -407,32 +602,4 @@ function encodeMultipart(parts, boundary) {
|
|
|
407
602
|
chunks.push(Buffer.from(`--${boundary}--${CRLF}`, 'utf8'));
|
|
408
603
|
return Buffer.concat(chunks);
|
|
409
604
|
}
|
|
410
|
-
/** Narrowing guard — a non-null, non-array object usable as a string map. */
|
|
411
|
-
function isRecord(x) {
|
|
412
|
-
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Partial deep-match. For objects, every key in `expected` must match in
|
|
416
|
-
* `actual`. For arrays, every element in `expected` must match SOMEWHERE in
|
|
417
|
-
* `actual` (order-independent). Primitives compared by strict equality.
|
|
418
|
-
*/
|
|
419
|
-
export function partialMatch(actual, expected) {
|
|
420
|
-
if (expected === null || expected === undefined) {
|
|
421
|
-
return actual === expected;
|
|
422
|
-
}
|
|
423
|
-
if (typeof expected !== 'object')
|
|
424
|
-
return actual === expected;
|
|
425
|
-
if (Array.isArray(expected)) {
|
|
426
|
-
if (!Array.isArray(actual))
|
|
427
|
-
return false;
|
|
428
|
-
return expected.every((want) => actual.some((have) => partialMatch(have, want)));
|
|
429
|
-
}
|
|
430
|
-
if (!isRecord(actual) || !isRecord(expected))
|
|
431
|
-
return false;
|
|
432
|
-
for (const key of Object.keys(expected)) {
|
|
433
|
-
if (!partialMatch(actual[key], expected[key]))
|
|
434
|
-
return false;
|
|
435
|
-
}
|
|
436
|
-
return true;
|
|
437
|
-
}
|
|
438
605
|
//# sourceMappingURL=RequestBuilder.js.map
|