@clickup/rest-client 2.10.293 → 2.10.296

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.eslintrc.base.js +20 -2
  2. package/README.md +3 -1
  3. package/SECURITY.md +39 -0
  4. package/dist/.eslintcache +1 -0
  5. package/dist/RestClient.js +1 -1
  6. package/dist/RestClient.js.map +1 -1
  7. package/dist/RestOptions.d.ts +5 -7
  8. package/dist/RestOptions.d.ts.map +1 -1
  9. package/dist/RestOptions.js +2 -2
  10. package/dist/RestOptions.js.map +1 -1
  11. package/dist/RestRequest.d.ts.map +1 -1
  12. package/dist/RestRequest.js +7 -13
  13. package/dist/RestRequest.js.map +1 -1
  14. package/dist/RestResponse.d.ts +4 -1
  15. package/dist/RestResponse.d.ts.map +1 -1
  16. package/dist/RestResponse.js +2 -1
  17. package/dist/RestResponse.js.map +1 -1
  18. package/dist/RestStream.js.map +1 -1
  19. package/dist/errors/RestRateLimitError.d.ts.map +1 -1
  20. package/dist/errors/RestRateLimitError.js.map +1 -1
  21. package/dist/errors/RestRetriableError.d.ts.map +1 -1
  22. package/dist/errors/RestRetriableError.js.map +1 -1
  23. package/dist/errors/RestTokenInvalidError.d.ts.map +1 -1
  24. package/dist/errors/RestTokenInvalidError.js.map +1 -1
  25. package/dist/helpers/depaginate.d.ts +1 -1
  26. package/dist/helpers/depaginate.d.ts.map +1 -1
  27. package/dist/helpers/depaginate.js.map +1 -1
  28. package/dist/internal/RestFetchReader.d.ts +9 -2
  29. package/dist/internal/RestFetchReader.d.ts.map +1 -1
  30. package/dist/internal/RestFetchReader.js +14 -3
  31. package/dist/internal/RestFetchReader.js.map +1 -1
  32. package/dist/internal/RestRangeUploader.js.map +1 -1
  33. package/dist/internal/calcRetryDelay.js.map +1 -1
  34. package/dist/internal/inferResBodyEncoding.js.map +1 -1
  35. package/dist/internal/inspectPossibleJSON.js.map +1 -1
  36. package/dist/internal/substituteParams.js.map +1 -1
  37. package/dist/internal/throwIfErrorResponse.js.map +1 -1
  38. package/dist/middlewares/paceRequests.js.map +1 -1
  39. package/dist/pacers/PacerQPS.js.map +1 -1
  40. package/docs/README.md +3 -1
  41. package/docs/classes/PacerComposite.md +7 -3
  42. package/docs/classes/PacerQPS.md +7 -3
  43. package/docs/classes/RestClient.md +32 -28
  44. package/docs/classes/RestContentSizeOverLimitError.md +5 -1
  45. package/docs/classes/RestError.md +5 -1
  46. package/docs/classes/RestRateLimitError.md +6 -2
  47. package/docs/classes/RestRequest.md +22 -18
  48. package/docs/classes/RestResponse.md +25 -10
  49. package/docs/classes/RestResponseError.md +5 -1
  50. package/docs/classes/RestRetriableError.md +6 -2
  51. package/docs/classes/RestStream.md +12 -8
  52. package/docs/classes/RestTimeoutError.md +5 -1
  53. package/docs/classes/RestTokenInvalidError.md +6 -2
  54. package/docs/interfaces/Middleware.md +4 -4
  55. package/docs/interfaces/Pacer.md +2 -2
  56. package/docs/interfaces/PacerQPSBackend.md +2 -2
  57. package/docs/interfaces/RestLogEvent.md +1 -1
  58. package/docs/interfaces/RestOptions.md +59 -23
  59. package/docs/interfaces/TokenGetter.md +3 -3
  60. package/docs/modules.md +4 -4
  61. package/jest.config.js +3 -0
  62. package/package.json +40 -8
  63. package/src/RestClient.ts +490 -0
  64. package/src/RestOptions.ts +186 -0
  65. package/src/RestRequest.ts +441 -0
  66. package/src/RestResponse.ts +49 -0
  67. package/src/RestStream.ts +89 -0
  68. package/src/errors/RestContentSizeOverLimitError.ts +3 -0
  69. package/src/errors/RestError.ts +6 -0
  70. package/src/errors/RestRateLimitError.ts +12 -0
  71. package/src/errors/RestResponseError.ts +46 -0
  72. package/src/errors/RestRetriableError.ts +12 -0
  73. package/src/errors/RestTimeoutError.ts +3 -0
  74. package/src/errors/RestTokenInvalidError.ts +11 -0
  75. package/src/helpers/depaginate.ts +37 -0
  76. package/src/index.ts +50 -0
  77. package/src/internal/RestFetchReader.ts +188 -0
  78. package/src/internal/RestRangeUploader.ts +61 -0
  79. package/src/internal/calcRetryDelay.ts +59 -0
  80. package/src/internal/inferResBodyEncoding.ts +33 -0
  81. package/src/internal/inspectPossibleJSON.ts +71 -0
  82. package/src/internal/prependNewlineIfMultiline.ts +3 -0
  83. package/src/internal/substituteParams.ts +25 -0
  84. package/src/internal/throwIfErrorResponse.ts +89 -0
  85. package/src/internal/toFloatMs.ts +3 -0
  86. package/src/middlewares/paceRequests.ts +42 -0
  87. package/src/pacers/Pacer.ts +22 -0
  88. package/src/pacers/PacerComposite.ts +29 -0
  89. package/src/pacers/PacerQPS.ts +147 -0
  90. package/tsconfig.json +5 -13
  91. package/typedoc.json +6 -1
  92. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,186 @@
1
+ import { Agent as HttpAgent } from "http";
2
+ import { Agent as HttpsAgent } from "https";
3
+ import delay from "delay";
4
+ import { Memoize } from "fast-typescript-memoize";
5
+ import type RestRequest from "./RestRequest";
6
+ import type RestResponse from "./RestResponse";
7
+
8
+ /**
9
+ * An event which is passed to an external logger (see RestOptions).
10
+ */
11
+ export interface RestLogEvent {
12
+ attempt: number;
13
+ req: RestRequest;
14
+ res: RestResponse | "backoff_delay" | null;
15
+ exception: any | null;
16
+ timestamp: number;
17
+ elapsed: number;
18
+ isFinalAttempt: boolean;
19
+ privateDataInResponse: boolean;
20
+ comment: string;
21
+ }
22
+
23
+ /**
24
+ * Middlewares allow to modify RestRequest and RestResponse objects during the
25
+ * request processing.
26
+ */
27
+ export interface Middleware {
28
+ (
29
+ req: RestRequest,
30
+ next: (req: RestRequest) => Promise<RestResponse>,
31
+ ): Promise<RestResponse>;
32
+ }
33
+
34
+ /**
35
+ * @ignore
36
+ * Parameters for Agents.
37
+ */
38
+ interface AgentOptions {
39
+ keepAlive: boolean;
40
+ timeout?: number;
41
+ maxSockets?: number;
42
+ rejectUnauthorized?: boolean;
43
+ family?: 4 | 6 | 0;
44
+ }
45
+
46
+ /**
47
+ * @ignore
48
+ * An internal class which keeps the list of HttpAgent instances used in a
49
+ * particular RestClient instance.
50
+ */
51
+ export class Agents {
52
+ @Memoize((...args: any[]) => JSON.stringify(args))
53
+ http(options: AgentOptions) {
54
+ return new HttpAgent(options);
55
+ }
56
+
57
+ @Memoize((...args: any[]) => JSON.stringify(args))
58
+ https(options: AgentOptions) {
59
+ return new HttpsAgent(options);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Options passed to RestClient. More options can be added by cloning an
65
+ * instance of RestClient, withOption() method.
66
+ */
67
+ export default interface RestOptions {
68
+ /** Max number of retries. Default is 0, because some requests are from the
69
+ * web app, and we don't want to retry them. */
70
+ retries: number;
71
+ /** How much time to wait by default on the 1st retry attempt. */
72
+ retryDelayFirstMs: number;
73
+ /** How much to increase the retry delay on each retry. */
74
+ retryDelayFactor: number;
75
+ /** Use this fraction (random) of the current retry delay to jitter both ways
76
+ * (e.g. 0.1 means 90%...110% of the delay to be actually applied). */
77
+ retryDelayJitter: number;
78
+ /** Maximum delay between each retry. */
79
+ retryDelayMaxMs: number;
80
+ /** A logic which runs on different IO stages (delay and heartbeats). */
81
+ heartbeater: {
82
+ /* This function is called after each request with 0 passed, so it can serve
83
+ * the purpose of a heartbeat. */
84
+ heartbeat(): Promise<void>;
85
+ /** A function which, when runs, resolves in the provided number of ms. Can
86
+ * be used for several purposes, like overriding in unit tests or to pass a
87
+ * custom delay implementation which can throw on some external event (like
88
+ * when the process wants to gracefully stop). */
89
+ delay(ms: number): Promise<void>;
90
+ };
91
+ /** Allows to limit huge requests and throw instead. */
92
+ throwIfResIsBigger: number | undefined;
93
+ /** Passed to the logger which may decide, should it log details of the
94
+ * response or not. */
95
+ privateDataInResponse: boolean;
96
+ /** If true, non-public IP addresses are allowed too; otherwise, only unicast
97
+ * addresses are allowed. */
98
+ allowInternalIPs: boolean;
99
+ /** If true, logs request-response pairs to console. */
100
+ isDebug: boolean;
101
+ /** @ignore Holds HttpsAgent/HttpAgent instances; used internally only. */
102
+ agents: Agents;
103
+ /** Sets Keep-Alive parameters (persistent connections). */
104
+ keepAlive: {
105
+ /** How much time to keep an idle connection alive in the pool. If 0, closes
106
+ * the connection immediately after the response. */
107
+ timeoutMs: number;
108
+ /** How many sockets at maximum will be kept open. */
109
+ maxSockets?: number;
110
+ };
111
+ /** When resolving DNS, use IPv4, IPv6 or both (see dns.lookup() docs). */
112
+ family: 4 | 6 | 0;
113
+ /** Max timeout to wait for a response. */
114
+ timeoutMs: number;
115
+ /** Logger to be used for each responses (including retried) plus for backoff
116
+ * delay events logging. */
117
+ logger: (event: RestLogEvent) => void;
118
+ /** Middlewares to wrap requests. May alter both request and response. */
119
+ middlewares: Middleware[];
120
+ /** If set, makes decision whether the response is successful or not. The
121
+ * response will either be returned to the client, or an error will be thrown.
122
+ * This allows to treat some non-successful HTTP statuses as success if the
123
+ * remote API is that weird. Return values:
124
+ * * "SUCCESS" - the request will be considered successful, no further checks
125
+ * will be performed;
126
+ * * "BEST_EFFORT" - inconclusive, the request may be either successful or
127
+ * unsuccessful, additional tests (e.g. will check HTTP status code) will be
128
+ * performed;
129
+ * * "THROW" - the request resulted in error. Additional tests will be
130
+ * performed to determine is the error is retriable, is OAuth token good,
131
+ * and etc. */
132
+ isSuccessResponse: (res: RestResponse) => "SUCCESS" | "THROW" | "BEST_EFFORT";
133
+ /** Decides whether the response is a rate-limit error or not. Returning
134
+ * non-zero value is treated as retry delay (if retries are set up). In case
135
+ * the returned value is "SOMETHING_ELSE", the response ought to be either
136
+ * success or some other error. Returning "BEST_EFFORT" turns on built-in
137
+ * heuristic (e.g. relying on HTTP status code and Retry-After header). In
138
+ * case we've made a decision that it's a rate limited error, the request is
139
+ * always retried; this covers a very common case when we have both
140
+ * isRateLimitError and isRetriableError handlers set up, and they return
141
+ * contradictory information; then isRateLimitError wins. */
142
+ isRateLimitError: (
143
+ res: RestResponse,
144
+ ) => "SOMETHING_ELSE" | "RATE_LIMIT" | "BEST_EFFORT" | number;
145
+ /** Decides whether the response is a token-invalid error or not. In case it's
146
+ * not, the response ought to be either success or some other error. */
147
+ isTokenInvalidError: (res: RestResponse) => boolean;
148
+ /** Called only if we haven't decided earlier that it's a rate limit error.
149
+ * Decides whether the response is a retriable error or not. In case the
150
+ * returned value is "NEVER_RETRY", the response ought to be either success or
151
+ * some other error, but it's guaranteed that the request won't be retried.
152
+ * Returning "BEST_EFFORT" turns on built-in heuristics (e.g. never retry "not
153
+ * found" errors). Returning a number is treated as "RETRY", and the next
154
+ * retry will happen in not less than this number of milliseconds. */
155
+ isRetriableError: (
156
+ res: RestResponse,
157
+ _error: any,
158
+ ) => "NEVER_RETRY" | "RETRY" | "BEST_EFFORT" | number;
159
+ }
160
+
161
+ /** @ignore */
162
+ export const DEFAULT_OPTIONS: RestOptions = {
163
+ retries: 0,
164
+ retryDelayFirstMs: 1000,
165
+ retryDelayFactor: 2,
166
+ retryDelayJitter: 0.1,
167
+ retryDelayMaxMs: Number.MAX_SAFE_INTEGER,
168
+ heartbeater: {
169
+ heartbeat: async () => {},
170
+ delay,
171
+ },
172
+ throwIfResIsBigger: undefined,
173
+ privateDataInResponse: false,
174
+ allowInternalIPs: false,
175
+ isDebug: false,
176
+ agents: new Agents(),
177
+ keepAlive: { timeoutMs: 10000 },
178
+ family: 4, // we don't want to hit ~5s DNS timeout on a missing IPv6 by default
179
+ timeoutMs: 4 * 60 * 1000,
180
+ logger: () => {},
181
+ middlewares: [],
182
+ isSuccessResponse: () => "BEST_EFFORT",
183
+ isRateLimitError: () => "BEST_EFFORT",
184
+ isTokenInvalidError: () => false,
185
+ isRetriableError: () => "BEST_EFFORT",
186
+ };
@@ -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
+ }