@clickup/rest-client 2.10.293 → 2.10.294
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/.eslintrc.base.js +18 -1
- package/README.md +1 -1
- package/SECURITY.md +39 -0
- package/dist/.eslintcache +1 -0
- package/dist/RestClient.js +1 -1
- package/dist/RestClient.js.map +1 -1
- package/dist/RestOptions.d.ts +5 -7
- package/dist/RestOptions.d.ts.map +1 -1
- package/dist/RestOptions.js +2 -2
- package/dist/RestOptions.js.map +1 -1
- package/dist/RestRequest.d.ts.map +1 -1
- package/dist/RestRequest.js +7 -13
- package/dist/RestRequest.js.map +1 -1
- package/dist/RestResponse.d.ts +4 -1
- package/dist/RestResponse.d.ts.map +1 -1
- package/dist/RestResponse.js +2 -1
- package/dist/RestResponse.js.map +1 -1
- package/dist/RestStream.js.map +1 -1
- package/dist/helpers/depaginate.d.ts +1 -1
- package/dist/helpers/depaginate.d.ts.map +1 -1
- package/dist/helpers/depaginate.js.map +1 -1
- package/dist/internal/RestFetchReader.d.ts +9 -2
- package/dist/internal/RestFetchReader.d.ts.map +1 -1
- package/dist/internal/RestFetchReader.js +14 -3
- package/dist/internal/RestFetchReader.js.map +1 -1
- package/dist/internal/RestRangeUploader.js.map +1 -1
- package/dist/internal/calcRetryDelay.js.map +1 -1
- package/dist/internal/inspectPossibleJSON.js.map +1 -1
- package/dist/internal/substituteParams.js.map +1 -1
- package/dist/internal/throwIfErrorResponse.js.map +1 -1
- package/dist/middlewares/paceRequests.js.map +1 -1
- package/dist/pacers/PacerQPS.js.map +1 -1
- package/docs/README.md +1 -1
- package/docs/classes/RestResponse.md +19 -8
- package/docs/interfaces/RestOptions.md +20 -21
- package/docs/modules.md +1 -1
- package/jest.config.js +3 -0
- package/package.json +36 -7
- package/src/RestClient.ts +490 -0
- package/src/RestOptions.ts +186 -0
- package/src/RestRequest.ts +441 -0
- package/src/RestResponse.ts +49 -0
- package/src/RestStream.ts +89 -0
- package/src/errors/RestContentSizeOverLimitError.ts +3 -0
- package/src/errors/RestError.ts +6 -0
- package/src/errors/RestRateLimitError.ts +8 -0
- package/src/errors/RestResponseError.ts +46 -0
- package/src/errors/RestRetriableError.ts +8 -0
- package/src/errors/RestTimeoutError.ts +3 -0
- package/src/errors/RestTokenInvalidError.ts +8 -0
- package/src/helpers/depaginate.ts +37 -0
- package/src/index.ts +50 -0
- package/src/internal/RestFetchReader.ts +188 -0
- package/src/internal/RestRangeUploader.ts +61 -0
- package/src/internal/calcRetryDelay.ts +59 -0
- package/src/internal/inferResBodyEncoding.ts +33 -0
- package/src/internal/inspectPossibleJSON.ts +71 -0
- package/src/internal/prependNewlineIfMultiline.ts +3 -0
- package/src/internal/substituteParams.ts +25 -0
- package/src/internal/throwIfErrorResponse.ts +89 -0
- package/src/internal/toFloatMs.ts +3 -0
- package/src/middlewares/paceRequests.ts +42 -0
- package/src/pacers/Pacer.ts +22 -0
- package/src/pacers/PacerComposite.ts +29 -0
- package/src/pacers/PacerQPS.ts +147 -0
- package/tsconfig.json +3 -10
- package/typedoc.json +6 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import dns from "dns";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { Memoize } from "fast-typescript-memoize";
|
|
4
|
+
import { parse } from "ipaddr.js";
|
|
5
|
+
import random from "lodash/random";
|
|
6
|
+
import type { RequestInit, RequestRedirect } from "node-fetch";
|
|
7
|
+
import { Headers } from "node-fetch";
|
|
8
|
+
import RestContentSizeOverLimitError from "./errors/RestContentSizeOverLimitError";
|
|
9
|
+
import RestError from "./errors/RestError";
|
|
10
|
+
import RestTimeoutError from "./errors/RestTimeoutError";
|
|
11
|
+
import calcRetryDelay from "./internal/calcRetryDelay";
|
|
12
|
+
import inspectPossibleJSON from "./internal/inspectPossibleJSON";
|
|
13
|
+
import RestFetchReader from "./internal/RestFetchReader";
|
|
14
|
+
import throwIfErrorResponse from "./internal/throwIfErrorResponse";
|
|
15
|
+
import toFloatMs from "./internal/toFloatMs";
|
|
16
|
+
import type RestOptions from "./RestOptions";
|
|
17
|
+
import type { RestLogEvent } from "./RestOptions";
|
|
18
|
+
import RestResponse from "./RestResponse";
|
|
19
|
+
import RestStream from "./RestStream";
|
|
20
|
+
|
|
21
|
+
const MAX_DEBUG_LEN = 1024 * 100;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Type TAssertShape allows to limit json()'s assert callbacks to only those
|
|
25
|
+
* which return an object compatible with TAssertShape.
|
|
26
|
+
*/
|
|
27
|
+
export default class RestRequest<TAssertShape = any> {
|
|
28
|
+
readonly options: RestOptions;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
options: RestOptions,
|
|
32
|
+
public readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
|
33
|
+
public url: string,
|
|
34
|
+
public readonly headers: Headers,
|
|
35
|
+
public readonly body: string | Buffer | NodeJS.ReadableStream,
|
|
36
|
+
public readonly shape?: string
|
|
37
|
+
) {
|
|
38
|
+
this.options = { ...options };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Modifies the request by adding a custom HTTP header.
|
|
43
|
+
*/
|
|
44
|
+
setHeader(name: string, value: string) {
|
|
45
|
+
this.headers.append(name, value);
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Modifies the request by adding a custom request option.
|
|
51
|
+
*/
|
|
52
|
+
setOptions(options: Partial<RestOptions>) {
|
|
53
|
+
for (const [k, v] of Object.entries(options)) {
|
|
54
|
+
(this.options as any)[k] = v;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Forces RestClient to debug-output the request and response to console.
|
|
62
|
+
* Never use in production.
|
|
63
|
+
*/
|
|
64
|
+
setDebug(flag = true) {
|
|
65
|
+
this.options.isDebug = flag;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sends the request and reads the response a JSON. In absolute most of the
|
|
71
|
+
* cases, this method is used to reach API responses. The assert callback
|
|
72
|
+
* (typically generated by typescript-is) is intentionally made mandatory to
|
|
73
|
+
* not let people to do anti-patterns.
|
|
74
|
+
*/
|
|
75
|
+
async json<TJson extends TAssertShape>(
|
|
76
|
+
assert:
|
|
77
|
+
| ((obj: any) => TJson)
|
|
78
|
+
| { mask(obj: any): TJson }
|
|
79
|
+
| { $assert(obj: any): TJson },
|
|
80
|
+
...checkers: Array<(json: TJson, res: RestResponse) => false | Error>
|
|
81
|
+
): Promise<TJson> {
|
|
82
|
+
const res = await this.response();
|
|
83
|
+
|
|
84
|
+
// Support Superstruct in a duck-typing way.
|
|
85
|
+
if (typeof assert !== "function") {
|
|
86
|
+
if ("mask" in assert) {
|
|
87
|
+
assert = assert.mask.bind(assert);
|
|
88
|
+
} else if ("$assert" in assert) {
|
|
89
|
+
assert = assert.$assert.bind(assert);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const json = assert(res.json);
|
|
94
|
+
for (const checker of checkers) {
|
|
95
|
+
const error = checker(json, res);
|
|
96
|
+
if (error !== false) {
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return json as any;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sends the request and returns plaintext response.
|
|
106
|
+
*/
|
|
107
|
+
async text() {
|
|
108
|
+
const res = await this.response();
|
|
109
|
+
return res.text;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Returns the entire RestResponse object with response status and headers
|
|
114
|
+
* information in it. Try to minimize usage of this method, because it doesn't
|
|
115
|
+
* make any assumptions on the response structure.
|
|
116
|
+
*/
|
|
117
|
+
@Memoize()
|
|
118
|
+
async response(): Promise<RestResponse> {
|
|
119
|
+
const stream = await this.stream(
|
|
120
|
+
// By passing Number.MAX_SAFE_INTEGER to stream(), we ensure that the
|
|
121
|
+
// entire data will be preloaded, or the loading will fail due to
|
|
122
|
+
// throwIfResIsBigger limitation, whichever will happen faster.
|
|
123
|
+
Number.MAX_SAFE_INTEGER
|
|
124
|
+
);
|
|
125
|
+
await stream.close();
|
|
126
|
+
return stream.res;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sends the requests and returns RestStream object. You MUST iterate over
|
|
131
|
+
* this object entirely (or call its return() method), otherwise the
|
|
132
|
+
* connection will remain dangling.
|
|
133
|
+
*/
|
|
134
|
+
async stream(preloadChars = 128 * 1024): Promise<RestStream> {
|
|
135
|
+
const finalAttempt = Math.max(this.options.retries + 1, 1);
|
|
136
|
+
let retryDelayMs = Math.max(this.options.retryDelayFirstMs, 10);
|
|
137
|
+
|
|
138
|
+
for (let attempt = 1; attempt <= finalAttempt; attempt++) {
|
|
139
|
+
let timeStart = process.hrtime();
|
|
140
|
+
let res = null as RestResponse | null;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Middlewares work with RestResponse (can alter it) and not with
|
|
144
|
+
// RestStream intentionally. So the goal here is to create a
|
|
145
|
+
// RestResponse object, and then later derive a RestStream from it.
|
|
146
|
+
let reader = null as RestFetchReader | null;
|
|
147
|
+
res = await runMiddlewares(
|
|
148
|
+
this,
|
|
149
|
+
this.options.middlewares,
|
|
150
|
+
async (req) => {
|
|
151
|
+
reader = null;
|
|
152
|
+
try {
|
|
153
|
+
timeStart = process.hrtime();
|
|
154
|
+
res = null;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const fetchReq = await req._createFetchRequest();
|
|
158
|
+
reader = req._createFetchReader(fetchReq);
|
|
159
|
+
await reader.preload(preloadChars);
|
|
160
|
+
} finally {
|
|
161
|
+
res = reader
|
|
162
|
+
? req._createRestResponse(reader)
|
|
163
|
+
: new RestResponse(req, null, 0, new Headers(), "", false);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
throwIfErrorResponse(req.options, res);
|
|
167
|
+
|
|
168
|
+
req._logResponse({
|
|
169
|
+
attempt,
|
|
170
|
+
req,
|
|
171
|
+
res,
|
|
172
|
+
exception: null,
|
|
173
|
+
timestamp: Date.now(),
|
|
174
|
+
elapsed: toFloatMs(process.hrtime(timeStart)),
|
|
175
|
+
isFinalAttempt: attempt === finalAttempt,
|
|
176
|
+
privateDataInResponse: req.options.privateDataInResponse,
|
|
177
|
+
comment: "",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return res;
|
|
181
|
+
} catch (e: unknown) {
|
|
182
|
+
await reader?.close();
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// The only place where we return the response. Otherwise we retry or
|
|
189
|
+
// throw an exception.
|
|
190
|
+
return new RestStream(res, reader!);
|
|
191
|
+
} catch (error: unknown) {
|
|
192
|
+
this._logResponse({
|
|
193
|
+
attempt,
|
|
194
|
+
req: this,
|
|
195
|
+
res,
|
|
196
|
+
exception: error,
|
|
197
|
+
timestamp: Date.now(),
|
|
198
|
+
elapsed: toFloatMs(process.hrtime(timeStart)),
|
|
199
|
+
isFinalAttempt: attempt === finalAttempt,
|
|
200
|
+
privateDataInResponse: this.options.privateDataInResponse,
|
|
201
|
+
comment: "",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (res === null) {
|
|
205
|
+
// An error in internal function or middleware; this must not happen.
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (attempt === finalAttempt) {
|
|
210
|
+
// Last retry attempt; always throw.
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const newRetryDelay = calcRetryDelay(
|
|
215
|
+
error,
|
|
216
|
+
this.options,
|
|
217
|
+
res,
|
|
218
|
+
retryDelayMs
|
|
219
|
+
);
|
|
220
|
+
if (newRetryDelay === "no_retry") {
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
retryDelayMs = newRetryDelay;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const delayStart = process.hrtime();
|
|
228
|
+
retryDelayMs *= random(
|
|
229
|
+
1 - this.options.retryDelayJitter,
|
|
230
|
+
1 + this.options.retryDelayJitter,
|
|
231
|
+
true
|
|
232
|
+
);
|
|
233
|
+
await this.options.heartbeater.delay(retryDelayMs);
|
|
234
|
+
|
|
235
|
+
this._logResponse({
|
|
236
|
+
attempt,
|
|
237
|
+
req: this,
|
|
238
|
+
res: "backoff_delay",
|
|
239
|
+
exception: null,
|
|
240
|
+
timestamp: Date.now(),
|
|
241
|
+
elapsed: toFloatMs(process.hrtime(delayStart)),
|
|
242
|
+
isFinalAttempt: false,
|
|
243
|
+
privateDataInResponse: this.options.privateDataInResponse,
|
|
244
|
+
comment: "",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
retryDelayMs *= this.options.retryDelayFactor;
|
|
248
|
+
retryDelayMs = Math.min(this.options.retryDelayMaxMs, retryDelayMs);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
throw Error("BUG: should never happen");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* We can actually only create RequestInit. We can't create an instance of
|
|
256
|
+
* node-fetch.Request object since it doesn't allow injection of
|
|
257
|
+
* AbortController later, and we don't want to deal with AbortController here.
|
|
258
|
+
*/
|
|
259
|
+
private async _createFetchRequest(): Promise<RequestInit & { url: string }> {
|
|
260
|
+
const url = new URL(this.url);
|
|
261
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
262
|
+
throw new RestError(`Unsupported protocol: ${url.protocol}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// TODO: rework DNS resolution to use a custom Agent. We'd be able to
|
|
266
|
+
// support safe redirects then.
|
|
267
|
+
|
|
268
|
+
// Resolve IP address, check it for being a public IP address and substitute
|
|
269
|
+
// it in the URL; the hostname will be passed separately via Host header.
|
|
270
|
+
const hostname = url.hostname;
|
|
271
|
+
const headers = new Headers(this.headers);
|
|
272
|
+
const addr = await promisify(dns.lookup)(hostname, {
|
|
273
|
+
family: this.options.family,
|
|
274
|
+
});
|
|
275
|
+
let redirectMode: RequestRedirect = "follow";
|
|
276
|
+
if (!this.options.allowInternalIPs) {
|
|
277
|
+
const range = parse(addr.address).range();
|
|
278
|
+
// External requests are returned as "unicast" by ipaddr.js.
|
|
279
|
+
const isInternal = range !== "unicast";
|
|
280
|
+
if (isInternal) {
|
|
281
|
+
throw new RestError(
|
|
282
|
+
`Domain ${hostname} resolves to a non-public (${range}) IP address ${addr.address}`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
url.hostname = addr.address;
|
|
287
|
+
if (!headers.get("host")) {
|
|
288
|
+
headers.set("host", hostname);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ATTENTION: don't turn on redirects, it's a security breach when using
|
|
292
|
+
// with allowInternalIPs=false which is default!
|
|
293
|
+
redirectMode = "error";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const hasKeepAlive = this.options.keepAlive.timeoutMs > 0;
|
|
297
|
+
if (hasKeepAlive) {
|
|
298
|
+
headers.append("connection", "Keep-Alive");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Use lazily created/cached per-RestClient Agent instance to utilize HTTP
|
|
302
|
+
// persistent connections and save on HTTPS connection re-establishment.
|
|
303
|
+
const agent = (
|
|
304
|
+
url.protocol === "https:"
|
|
305
|
+
? this.options.agents.https.bind(this.options.agents)
|
|
306
|
+
: this.options.agents.http.bind(this.options.agents)
|
|
307
|
+
)({
|
|
308
|
+
keepAlive: hasKeepAlive,
|
|
309
|
+
timeout: hasKeepAlive ? this.options.keepAlive.timeoutMs : undefined,
|
|
310
|
+
maxSockets: this.options.keepAlive.maxSockets,
|
|
311
|
+
rejectUnauthorized: this.options.allowInternalIPs ? false : undefined,
|
|
312
|
+
family: this.options.family,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
url: url.toString(),
|
|
317
|
+
method: this.method,
|
|
318
|
+
headers,
|
|
319
|
+
body: this.body || undefined,
|
|
320
|
+
timeout: this.options.timeoutMs,
|
|
321
|
+
redirect: redirectMode,
|
|
322
|
+
agent,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Creates an instance of RestFetchReader.
|
|
328
|
+
*/
|
|
329
|
+
private _createFetchReader(req: RequestInit & { url: string }) {
|
|
330
|
+
return new RestFetchReader(req.url, req, {
|
|
331
|
+
timeoutMs: this.options.timeoutMs,
|
|
332
|
+
heartbeat: async () => this.options.heartbeater.heartbeat(),
|
|
333
|
+
onTimeout: (reader) => {
|
|
334
|
+
throw new RestTimeoutError(
|
|
335
|
+
`Timed out while reading response body (${this.options.timeoutMs} ms)`,
|
|
336
|
+
this._createRestResponse(reader)!
|
|
337
|
+
);
|
|
338
|
+
},
|
|
339
|
+
onAfterRead: (reader) => {
|
|
340
|
+
if (
|
|
341
|
+
this.options.throwIfResIsBigger &&
|
|
342
|
+
reader.charsRead > this.options.throwIfResIsBigger
|
|
343
|
+
) {
|
|
344
|
+
throw new RestContentSizeOverLimitError(
|
|
345
|
+
`Content size is over limit of ${this.options.throwIfResIsBigger} characters`,
|
|
346
|
+
this._createRestResponse(reader)!
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Creates a RestResponse from a RestFetchReader. Assumes that
|
|
355
|
+
* RestFetchReader.preload() has already been called.
|
|
356
|
+
*/
|
|
357
|
+
private _createRestResponse(reader: RestFetchReader) {
|
|
358
|
+
return new RestResponse(
|
|
359
|
+
this,
|
|
360
|
+
reader.agent,
|
|
361
|
+
reader.status,
|
|
362
|
+
reader.headers,
|
|
363
|
+
reader.textFetched,
|
|
364
|
+
reader.textIsPartial
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Logs a response event, an error event or a backoff event. If RestResponse
|
|
370
|
+
* is not yet known (e.g. an exception happened in a DNS resolution), res must
|
|
371
|
+
* be passed as null.
|
|
372
|
+
*/
|
|
373
|
+
private _logResponse(event: RestLogEvent) {
|
|
374
|
+
this.options.logger(event);
|
|
375
|
+
|
|
376
|
+
// Debug-logging to console?
|
|
377
|
+
if (
|
|
378
|
+
this.options.isDebug ||
|
|
379
|
+
(process.env["NODE_ENV"] === "development" &&
|
|
380
|
+
event.res === "backoff_delay")
|
|
381
|
+
) {
|
|
382
|
+
let reqMessage =
|
|
383
|
+
`${this.method} ${this.url}\n` +
|
|
384
|
+
Object.entries(this.headers.raw())
|
|
385
|
+
.map(([k, vs]) => vs.map((v) => `${k}: ${v}`))
|
|
386
|
+
.join("\n");
|
|
387
|
+
if (this.body) {
|
|
388
|
+
reqMessage +=
|
|
389
|
+
"\n" + inspectPossibleJSON(this.headers, this.body, MAX_DEBUG_LEN);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let resMessage = "";
|
|
393
|
+
if (event.res === "backoff_delay") {
|
|
394
|
+
resMessage =
|
|
395
|
+
"Previous request failed, backoff delay elapsed, retrying attempt " +
|
|
396
|
+
(event.attempt + 1) +
|
|
397
|
+
"...";
|
|
398
|
+
} else if (event.res) {
|
|
399
|
+
resMessage =
|
|
400
|
+
`HTTP ${event.res.status} ` +
|
|
401
|
+
`(took ${Math.round(event.elapsed)} ms)`;
|
|
402
|
+
if (event.res.text) {
|
|
403
|
+
resMessage +=
|
|
404
|
+
"\n" +
|
|
405
|
+
inspectPossibleJSON(
|
|
406
|
+
event.res.headers,
|
|
407
|
+
event.res.text,
|
|
408
|
+
MAX_DEBUG_LEN
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
} else if (event.exception) {
|
|
412
|
+
resMessage = "" + event.exception;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// eslint-disable-next-line no-console
|
|
416
|
+
console.log(reqMessage.replace(/^/gm, "+++ "));
|
|
417
|
+
// eslint-disable-next-line no-console
|
|
418
|
+
console.log(resMessage.replace(/^/gm, "=== ") + "\n");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Runs the middlewares chain of responsibility. Each middleware receives a
|
|
425
|
+
* mutable RestRequest object and next() callback which, when called, triggers
|
|
426
|
+
* execution of the remaining middlewares in the chain. This allows a middleware
|
|
427
|
+
* to not only modify the request object, but also alter the response received
|
|
428
|
+
* from the subsequent middlewares.
|
|
429
|
+
*/
|
|
430
|
+
async function runMiddlewares(
|
|
431
|
+
req: RestRequest,
|
|
432
|
+
middlewares: RestOptions["middlewares"],
|
|
433
|
+
last: (req: RestRequest) => Promise<RestResponse>
|
|
434
|
+
): Promise<RestResponse> {
|
|
435
|
+
if (middlewares.length > 0) {
|
|
436
|
+
const [head, ...tail] = middlewares;
|
|
437
|
+
return head(req, async (req) => runMiddlewares(req, tail, last));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return last(req);
|
|
441
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Agent as HttpAgent } from "http";
|
|
2
|
+
import { Memoize } from "fast-typescript-memoize";
|
|
3
|
+
import type { Headers } from "node-fetch";
|
|
4
|
+
import type RestRequest from "./RestRequest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RestResponse is intentionally not aware of the data structure it carries, and
|
|
8
|
+
* it doesn't do any assertions/validations which is the responsibility of
|
|
9
|
+
* RestRequest helper methods.
|
|
10
|
+
*
|
|
11
|
+
* We also use a concept of "body preloading". Sometimes, e.g. on non-successful
|
|
12
|
+
* HTTP status codes, we also need to know the body content (at least its
|
|
13
|
+
* beginning), do double check whether should we retry, throw through or through
|
|
14
|
+
* a user-friendly error. To do this, we need to preload the beginning of the
|
|
15
|
+
* body and make it a part of RestResponse abstraction.
|
|
16
|
+
*/
|
|
17
|
+
export default class RestResponse {
|
|
18
|
+
constructor(
|
|
19
|
+
public readonly req: RestRequest,
|
|
20
|
+
public readonly agent: HttpAgent | null,
|
|
21
|
+
public readonly status: number,
|
|
22
|
+
public readonly headers: Headers,
|
|
23
|
+
public readonly text: string,
|
|
24
|
+
public readonly textIsPartial: boolean
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A safe way to treat the response as JSON.
|
|
29
|
+
* - Never throws, i.e. we imply that the caller will verify the structure of
|
|
30
|
+
* the response and do its own errors processing.
|
|
31
|
+
* - It's a getter, so we can use typescript-is'es is<xyz>() type guard, e.g.:
|
|
32
|
+
* `if (is<{ errors: any[] }>(res.json) && res.json.errors.length) { ... }`
|
|
33
|
+
*
|
|
34
|
+
* Notice that there is NO `assert()` abstraction inside RestResponse class.
|
|
35
|
+
* This is because RestClient sometimes substitutes the response with some
|
|
36
|
+
* sub-field (e.g. see writeGraphQLX() method), and we still need to run the
|
|
37
|
+
* assertion in such cases. By not having strong typing here, we intentionally
|
|
38
|
+
* make the use of this method harder, so people will prefer using
|
|
39
|
+
* RestRequest.json() instead.
|
|
40
|
+
*/
|
|
41
|
+
@Memoize()
|
|
42
|
+
get json(): object | string | number | boolean | null | undefined {
|
|
43
|
+
try {
|
|
44
|
+
return this.text ? JSON.parse(this.text) : undefined;
|
|
45
|
+
} catch (e: any) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Memoize } from "fast-typescript-memoize";
|
|
2
|
+
import type RestResponse from "./RestResponse";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Once created, RestStream must be iterated in full, otherwise the connection
|
|
6
|
+
* will remain dangling. Also, this class is where we hide the details of the
|
|
7
|
+
* actual stream reading using AsyncGenerator bridge abstraction.
|
|
8
|
+
*
|
|
9
|
+
* RestStream can also read binary data depending on the Content-Type response
|
|
10
|
+
* header and/or the charset provided there. The binary data is still returned
|
|
11
|
+
* as a string, one string character per each byte. To convert it to a Buffer,
|
|
12
|
+
* use something like `Buffer.from(responseText, "binary")`.
|
|
13
|
+
*/
|
|
14
|
+
export default class RestStream {
|
|
15
|
+
private _generator?: AsyncGenerator<string, void>;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
public readonly res: RestResponse,
|
|
19
|
+
readerIterable: {
|
|
20
|
+
[Symbol.asyncIterator]: () => AsyncGenerator<string, void>;
|
|
21
|
+
}
|
|
22
|
+
) {
|
|
23
|
+
this._generator = readerIterable[Symbol.asyncIterator]();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reads the prefix of the stream. Closes the connection after the read is
|
|
28
|
+
* done in all cases, so safe to be used to e.g. receive a trimmed response.
|
|
29
|
+
*/
|
|
30
|
+
async consumeReturningPrefix(maxChars: number): Promise<string> {
|
|
31
|
+
const text: string[] = [];
|
|
32
|
+
let length = 0;
|
|
33
|
+
for await (const chunk of this) {
|
|
34
|
+
// According to Google, in v8 string concatenation is as efficient as
|
|
35
|
+
// array or buffer joining.
|
|
36
|
+
text.push(chunk);
|
|
37
|
+
length += chunk.length;
|
|
38
|
+
if (length >= maxChars) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return text.join("").substring(0, maxChars);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Closes the connection.
|
|
48
|
+
*/
|
|
49
|
+
async close(): Promise<void> {
|
|
50
|
+
// First, try to interrupt the active iteration, if any.
|
|
51
|
+
await this[Symbol.asyncIterator]().return();
|
|
52
|
+
// It is possible that this.[Symbol.asyncIterator] has never been iterated
|
|
53
|
+
// before at all, so its `finally` got never executed. This happens when
|
|
54
|
+
// RestFetchReader#preload() consumed some small chunk of data, and then
|
|
55
|
+
// no-one iterated the remaining body in RestStream, they just called
|
|
56
|
+
// close() on it. So we recheck & close the RestFetchReader iterator if it
|
|
57
|
+
// is still there.
|
|
58
|
+
const generator = this._generator;
|
|
59
|
+
if (generator) {
|
|
60
|
+
delete this._generator;
|
|
61
|
+
await generator.return();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Allows to iterate over the entire stream of data. You must consume the
|
|
67
|
+
* entire iterable or at least call this.close(), otherwise the connection may
|
|
68
|
+
* remain open.
|
|
69
|
+
*/
|
|
70
|
+
@Memoize()
|
|
71
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<string, void> {
|
|
72
|
+
const generator = this._generator;
|
|
73
|
+
if (!generator) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
delete this._generator;
|
|
78
|
+
try {
|
|
79
|
+
yield this.res.text;
|
|
80
|
+
for await (const chunk of generator) {
|
|
81
|
+
yield chunk;
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
// The code enters here if the caller interrupted (returned) the iteration
|
|
85
|
+
// after receiving the 1st textFetched chunk.
|
|
86
|
+
await generator.return();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type RestResponse from "../RestResponse";
|
|
2
|
+
import RestResponseError from "./RestResponseError";
|
|
3
|
+
|
|
4
|
+
export default class RestRateLimitError extends RestResponseError {
|
|
5
|
+
constructor(message: string, public delayMs: number, res: RestResponse) {
|
|
6
|
+
super(message, res);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import inspectPossibleJSON from "../internal/inspectPossibleJSON";
|
|
2
|
+
import prependNewlineIfMultiline from "../internal/prependNewlineIfMultiline";
|
|
3
|
+
import type RestResponse from "../RestResponse";
|
|
4
|
+
import RestError from "./RestError";
|
|
5
|
+
|
|
6
|
+
const RESPONSE_MAX_LEN_IN_ERROR =
|
|
7
|
+
process.env["NODE_ENV"] === "development" ? Number.MAX_SAFE_INTEGER : 2048;
|
|
8
|
+
|
|
9
|
+
export default class RestResponseError extends RestError {
|
|
10
|
+
readonly res!: RestResponse;
|
|
11
|
+
|
|
12
|
+
readonly method: string;
|
|
13
|
+
readonly host: string;
|
|
14
|
+
readonly pathname: string;
|
|
15
|
+
readonly requestArgs: string;
|
|
16
|
+
readonly requestBody: string;
|
|
17
|
+
readonly responseHeaders: string;
|
|
18
|
+
|
|
19
|
+
constructor(message: string, res: RestResponse) {
|
|
20
|
+
super(
|
|
21
|
+
`HTTP ${res.status}: ` +
|
|
22
|
+
(message ? `${message}: ` : "") +
|
|
23
|
+
prependNewlineIfMultiline(
|
|
24
|
+
inspectPossibleJSON(res.headers, res.text, RESPONSE_MAX_LEN_IN_ERROR)
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
Object.defineProperty(this, "res", { value: res, enumerable: false }); // hidden from inspect()
|
|
28
|
+
const url = new URL(res.req.url);
|
|
29
|
+
const search = (url.search || "").replace(/^\?/, "");
|
|
30
|
+
this.method = res.req.method;
|
|
31
|
+
this.host = res.req.headers.get("host") || url.host;
|
|
32
|
+
this.pathname = url.pathname;
|
|
33
|
+
this.requestArgs = search.replace(/(?<=[&])/g, "\n");
|
|
34
|
+
this.requestBody = inspectPossibleJSON(
|
|
35
|
+
res.headers,
|
|
36
|
+
res.req.body,
|
|
37
|
+
RESPONSE_MAX_LEN_IN_ERROR
|
|
38
|
+
);
|
|
39
|
+
this.responseHeaders = [...res.headers]
|
|
40
|
+
.map(([name, value]) => `${name}: ${value}`)
|
|
41
|
+
.join("\n");
|
|
42
|
+
// Don't expose query string parameters in the message as they may contain
|
|
43
|
+
// sensitive information.
|
|
44
|
+
this.message += `\n ${this.method} ${url.protocol}//${url.host}${url.pathname}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type RestResponse from "../RestResponse";
|
|
2
|
+
import RestResponseError from "./RestResponseError";
|
|
3
|
+
|
|
4
|
+
export default class RestRetriableError extends RestResponseError {
|
|
5
|
+
constructor(message: string, public delayMs: number, res: RestResponse) {
|
|
6
|
+
super(message, res);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type RestResponse from "../RestResponse";
|
|
2
|
+
import RestResponseError from "./RestResponseError";
|
|
3
|
+
|
|
4
|
+
export default class RestTokenInvalidError extends RestResponseError {
|
|
5
|
+
constructor(public readonly humanReason: string, res: RestResponse) {
|
|
6
|
+
super(humanReason, res);
|
|
7
|
+
}
|
|
8
|
+
}
|