@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,490 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import isUndefined from "lodash/isUndefined";
|
|
3
|
+
import omitBy from "lodash/omitBy";
|
|
4
|
+
import { Headers } from "node-fetch";
|
|
5
|
+
import OAuth1 from "oauth-1.0a";
|
|
6
|
+
import RestTokenInvalidError from "./errors/RestTokenInvalidError";
|
|
7
|
+
import RestRangeUploader from "./internal/RestRangeUploader";
|
|
8
|
+
import substituteParams from "./internal/substituteParams";
|
|
9
|
+
import type RestOptions from "./RestOptions";
|
|
10
|
+
import { DEFAULT_OPTIONS } from "./RestOptions";
|
|
11
|
+
import RestRequest from "./RestRequest";
|
|
12
|
+
import type RestResponse from "./RestResponse";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A callback which returns access token, possibly after refreshing it, and also
|
|
16
|
+
* possibly before a retry on "invalid token" condition. I.e. it can be called
|
|
17
|
+
* once or twice (the 2nd time after the previous request error, and that error
|
|
18
|
+
* will be passed as a parameter).
|
|
19
|
+
*/
|
|
20
|
+
export interface TokenGetter<TData = string> {
|
|
21
|
+
(prevError: Error | null): Promise<TData>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* RestClient is an immutable object which allows to:
|
|
26
|
+
* 1. Send remote requests in different formats, in a caller-friendly manner.
|
|
27
|
+
* 2. Create a new RestClient objects deriving the current set of options and
|
|
28
|
+
* adding new ones.
|
|
29
|
+
*/
|
|
30
|
+
export default class RestClient {
|
|
31
|
+
private readonly _options: RestOptions;
|
|
32
|
+
|
|
33
|
+
constructor(options: Partial<RestOptions> = {}) {
|
|
34
|
+
this._options = {
|
|
35
|
+
...DEFAULT_OPTIONS,
|
|
36
|
+
...omitBy(options, isUndefined), // undefined === "fallback to default"
|
|
37
|
+
...(options.keepAlive ? { keepAlive: { ...options.keepAlive } } : {}),
|
|
38
|
+
...(options.middlewares ? { middlewares: [...options.middlewares] } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns a new RestClient with some options updated with the passed ones.
|
|
44
|
+
*/
|
|
45
|
+
withOptions(options: Partial<RestOptions>) {
|
|
46
|
+
return new RestClient({
|
|
47
|
+
...this._options,
|
|
48
|
+
...omitBy(options, isUndefined),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns a new RestClient with added middleware.
|
|
54
|
+
*/
|
|
55
|
+
withMiddleware(
|
|
56
|
+
middleware: RestOptions["middlewares"][0],
|
|
57
|
+
method: "unshift" | "push" = "push"
|
|
58
|
+
) {
|
|
59
|
+
const clone = new RestClient(this._options);
|
|
60
|
+
clone._options.middlewares[method](middleware);
|
|
61
|
+
return clone;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns a new RestClient with the base URL which will be prepended to all
|
|
66
|
+
* relative paths in get(), writeForm() etc. Allows to defer resolution of
|
|
67
|
+
* this base URL to the very late per-request moment. The complicated piece
|
|
68
|
+
* here is that, if we want base URL to be resolved asynchronously, we often
|
|
69
|
+
* times want to reuse the same RestClient object (to e.g. fetch some part of
|
|
70
|
+
* the base URL using already authenticated client). And a re-enterable call
|
|
71
|
+
* appears here which we must protect against in the code below.
|
|
72
|
+
*/
|
|
73
|
+
withBase(base: string | (() => Promise<string>)) {
|
|
74
|
+
return this.withMiddleware(async function withBaseMiddleware(req, next) {
|
|
75
|
+
// If URL is already absolute, we don't even need to call base() lambda
|
|
76
|
+
// to resolve it. Return early.
|
|
77
|
+
if (req.url.match(/^\w+:\/\//)) {
|
|
78
|
+
return next(req);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Re-enterable call detected, e.g. base() lambda issued a request
|
|
82
|
+
// through the same RestClient. We must just skip setting the base.
|
|
83
|
+
if (base === "") {
|
|
84
|
+
return next(req);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof base !== "string") {
|
|
88
|
+
const baseGetter = base;
|
|
89
|
+
base = ""; // disable re-enterable calls
|
|
90
|
+
try {
|
|
91
|
+
base = await baseGetter();
|
|
92
|
+
} catch (e: any) {
|
|
93
|
+
base = baseGetter;
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
req.url = new URL(req.url, base).toString();
|
|
99
|
+
return next(req);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Returns a new RestClient with a custom header.
|
|
105
|
+
*/
|
|
106
|
+
withHeader(name: string, value: string | (() => Promise<string>)) {
|
|
107
|
+
return this.withMiddleware(async function withHeaderMiddleware(req, next) {
|
|
108
|
+
req.headers.set(name, typeof value === "string" ? value : await value());
|
|
109
|
+
return next(req);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns a new RestClient with a bearer token authentication workflow.
|
|
115
|
+
* - RestClient supports interception of options.isTokenInvalid() signal and
|
|
116
|
+
* conversion it into RestTokenInvalidError exception.
|
|
117
|
+
* - If a token() is a lambda with 1 argument, it may be called the 2nd time
|
|
118
|
+
* when we get an isTokenInvalid() signal. In this case, the request is
|
|
119
|
+
* retried.
|
|
120
|
+
* - If token() is a lambda with 0 arguments, that means it doesn't want to
|
|
121
|
+
* watch for the isTokenInvalid() signal, so there is no sense in retrying
|
|
122
|
+
* the request either.
|
|
123
|
+
*
|
|
124
|
+
* From the first sight, it looks like options.isTokenInvalid() signal is
|
|
125
|
+
* coupled to setBearer() auth method only. But it's not true:
|
|
126
|
+
* isTokenInvalid() makes sense for ALL authentication methods actually (even
|
|
127
|
+
* for basic auth), and setBearer() is just one of "clients" which implements
|
|
128
|
+
* refreshing/retries on top of isTokenInvalid().
|
|
129
|
+
*
|
|
130
|
+
* Passing the token as lambda allows the caller to implement some complex
|
|
131
|
+
* logic, e.g.:
|
|
132
|
+
* - oauth2 tokens refreshing
|
|
133
|
+
* - marking the token as "revoked" in the database in case the refresh fails
|
|
134
|
+
* - marking the token as "revoked" after a failed request if refresh-token is
|
|
135
|
+
* not supported
|
|
136
|
+
*/
|
|
137
|
+
withBearer(token: TokenGetter, bearerPrefix = "Bearer ") {
|
|
138
|
+
return this.withMiddleware(async function withBearerMiddleware(req, next) {
|
|
139
|
+
return tokenRetryStrategy(token, async (tokenData) => {
|
|
140
|
+
req.headers.set("Authorization", bearerPrefix + tokenData);
|
|
141
|
+
return next(req);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Returns a new RestClient with oauth1 authentication workflow.
|
|
148
|
+
* - In case we get an options.isTokenInvalid() signal, the token() lambda is
|
|
149
|
+
* called the 2nd time with the error object, then the request is retries.
|
|
150
|
+
* This gives the lambda a chance to recover or update something in the
|
|
151
|
+
* database.
|
|
152
|
+
*
|
|
153
|
+
* We use a separate and small oauth-1.0a node library here, because the more
|
|
154
|
+
* popular one (https://www.npmjs.com/package/oauth) doesn't support signing
|
|
155
|
+
* of arbitrary requests, it can only send its own requests.
|
|
156
|
+
*/
|
|
157
|
+
withOAuth1(
|
|
158
|
+
consumer: { consumerKey: string; consumerSecret: string },
|
|
159
|
+
token: TokenGetter<{ token: string; tokenSecret: string }>
|
|
160
|
+
) {
|
|
161
|
+
const oauth = new OAuth1({
|
|
162
|
+
consumer: { key: consumer.consumerKey, secret: consumer.consumerSecret },
|
|
163
|
+
signature_method: "HMAC-SHA1",
|
|
164
|
+
hash_function: (baseString, key) =>
|
|
165
|
+
crypto.createHmac("sha1", key).update(baseString).digest("base64"),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return this.withMiddleware(async function withOAuth1Middleware(req, next) {
|
|
169
|
+
return tokenRetryStrategy(token, async (tokenData) => {
|
|
170
|
+
const requestData: OAuth1.RequestOptions = {
|
|
171
|
+
url: req.url,
|
|
172
|
+
method: req.method,
|
|
173
|
+
data: req.body,
|
|
174
|
+
};
|
|
175
|
+
const addHeaders = oauth.toHeader(
|
|
176
|
+
oauth.authorize(requestData, {
|
|
177
|
+
key: tokenData.token,
|
|
178
|
+
secret: tokenData.tokenSecret,
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
for (const [name, value] of Object.entries(addHeaders)) {
|
|
182
|
+
req.headers.set(name, value);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return next(req);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Returns a new RestClient with basic authorization workflow.
|
|
192
|
+
*/
|
|
193
|
+
withBasic(token: TokenGetter<{ name: string; password: string }>) {
|
|
194
|
+
return this.withMiddleware(async function withBasicMiddleware(req, next) {
|
|
195
|
+
return tokenRetryStrategy(token, async ({ name, password }) => {
|
|
196
|
+
const unencodedHeader = name + ":" + password;
|
|
197
|
+
req.headers.set(
|
|
198
|
+
"Authorization",
|
|
199
|
+
"Basic " + Buffer.from(unencodedHeader).toString("base64")
|
|
200
|
+
);
|
|
201
|
+
return next(req);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Sends a plain GET request without body.
|
|
208
|
+
*
|
|
209
|
+
* NOTE, all args will be passed through `encodeURIComponent`.
|
|
210
|
+
*/
|
|
211
|
+
get(
|
|
212
|
+
path: string,
|
|
213
|
+
args: Partial<Record<string, string | number | string[]>> = {},
|
|
214
|
+
accept: string = "application/json"
|
|
215
|
+
) {
|
|
216
|
+
return this._noBodyRequest(path, args, "GET", accept);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Writes some raw string, buffer or a stream.
|
|
221
|
+
*/
|
|
222
|
+
writeRaw(
|
|
223
|
+
path: string,
|
|
224
|
+
body: string | Buffer | NodeJS.ReadableStream,
|
|
225
|
+
contentType: string,
|
|
226
|
+
method: "POST" | "PUT" | "PATCH" = "POST",
|
|
227
|
+
accept?: string
|
|
228
|
+
) {
|
|
229
|
+
const origShape = simpleShape(path);
|
|
230
|
+
return new RestRequest(
|
|
231
|
+
this._options,
|
|
232
|
+
method,
|
|
233
|
+
path,
|
|
234
|
+
new Headers({
|
|
235
|
+
Accept: accept || contentType,
|
|
236
|
+
"Content-Type": contentType,
|
|
237
|
+
}),
|
|
238
|
+
body,
|
|
239
|
+
origShape
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A shortcut method to write JSON body.
|
|
245
|
+
*/
|
|
246
|
+
writeJson(
|
|
247
|
+
path: string,
|
|
248
|
+
body: any,
|
|
249
|
+
method: "POST" | "PUT" | "PATCH" | "DELETE" = "POST",
|
|
250
|
+
accept: string = "application/json"
|
|
251
|
+
) {
|
|
252
|
+
const origShape = simpleShape(path, body);
|
|
253
|
+
[path, body] = substituteParams(path, body);
|
|
254
|
+
return new RestRequest(
|
|
255
|
+
this._options,
|
|
256
|
+
method,
|
|
257
|
+
path,
|
|
258
|
+
new Headers({
|
|
259
|
+
Accept: accept || "application/json",
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
}),
|
|
262
|
+
JSON.stringify(body),
|
|
263
|
+
origShape
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A shortcut method to write "application/x-www-form-urlencoded" data.
|
|
269
|
+
*/
|
|
270
|
+
writeForm(
|
|
271
|
+
path: string,
|
|
272
|
+
body: Partial<Record<string, string>> | string,
|
|
273
|
+
method: "POST" | "PUT" | "PATCH" = "POST",
|
|
274
|
+
accept: string = "application/json"
|
|
275
|
+
) {
|
|
276
|
+
const origShape = simpleShape(path, body);
|
|
277
|
+
[path, body] = substituteParams(path, body);
|
|
278
|
+
return new RestRequest(
|
|
279
|
+
this._options,
|
|
280
|
+
method,
|
|
281
|
+
path,
|
|
282
|
+
new Headers({
|
|
283
|
+
Accept: accept,
|
|
284
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
285
|
+
}),
|
|
286
|
+
typeof body === "string"
|
|
287
|
+
? body
|
|
288
|
+
: new URLSearchParams(
|
|
289
|
+
omitBy(body, isUndefined) as Record<string, string>
|
|
290
|
+
).toString(),
|
|
291
|
+
origShape
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A shortcut method to write DELETE request.
|
|
297
|
+
*/
|
|
298
|
+
writeDelete(
|
|
299
|
+
path: string,
|
|
300
|
+
args: Partial<Record<string, string>> = {},
|
|
301
|
+
accept: string = "application/json"
|
|
302
|
+
) {
|
|
303
|
+
return this._noBodyRequest(path, args, "DELETE", accept);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Returns a RestRequest prepared for sending GraphQL operation.
|
|
308
|
+
* - Expects the response to contain no errors; throws otherwise.
|
|
309
|
+
* - In case of success, returns just the content of `data` field (this is
|
|
310
|
+
* different with writeGraphQLNullable() which returns `data` as a separate
|
|
311
|
+
* fields along with `error` and `errors`).
|
|
312
|
+
*/
|
|
313
|
+
writeGraphQLX(query: string, variables: any = {}) {
|
|
314
|
+
const oldIsSuccessResponse = this._options.isSuccessResponse;
|
|
315
|
+
return this.withOptions({
|
|
316
|
+
isSuccessResponse: (res) => {
|
|
317
|
+
const oldIsSuccess = oldIsSuccessResponse?.(res);
|
|
318
|
+
const json: any = res.json;
|
|
319
|
+
return oldIsSuccess === "THROW" ||
|
|
320
|
+
!json?.data ||
|
|
321
|
+
json.error ||
|
|
322
|
+
(json.errors instanceof Array && json.errors.length > 0)
|
|
323
|
+
? "THROW"
|
|
324
|
+
: "BEST_EFFORT";
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
.withMiddleware(async function writeGraphQLXMiddleware(req, next) {
|
|
328
|
+
const res = await next(req);
|
|
329
|
+
const json: any = res.json;
|
|
330
|
+
// Substitute the response json object with just its data field.
|
|
331
|
+
Object.defineProperty(res, "json", { value: json.data });
|
|
332
|
+
return res;
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
._writeGraphQLImpl<any>(query, variables);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Same as writeGraphQLX(), but doesn't throw if GraphQL response contains
|
|
340
|
+
* non-empty `error` or `errors` fields and instead returns the full response.
|
|
341
|
+
* I.e. allows the caller to process these errors.
|
|
342
|
+
*/
|
|
343
|
+
writeGraphQLNullable(query: string, variables: any = {}) {
|
|
344
|
+
return this._writeGraphQLImpl<
|
|
345
|
+
{ data?: any; error?: any; errors?: any[] } | null | undefined
|
|
346
|
+
>(query, variables);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Performs a series of Content-Range requests with content from a sequence of
|
|
351
|
+
* Buffers.
|
|
352
|
+
*/
|
|
353
|
+
async rangeUpload(
|
|
354
|
+
path: string,
|
|
355
|
+
mimeType: string,
|
|
356
|
+
stream: AsyncIterable<Buffer>,
|
|
357
|
+
method: "POST" | "PUT" = "POST",
|
|
358
|
+
chunkSize: number
|
|
359
|
+
) {
|
|
360
|
+
return new RestRangeUploader(
|
|
361
|
+
this,
|
|
362
|
+
chunkSize,
|
|
363
|
+
method,
|
|
364
|
+
path,
|
|
365
|
+
mimeType
|
|
366
|
+
).upload(stream);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private _writeGraphQLImpl<TAssertShape>(query: string, variables: any = {}) {
|
|
370
|
+
const origShape = query;
|
|
371
|
+
// To beautify the query, we remove leading spaces (extracted from the last line).
|
|
372
|
+
query = query.trimEnd();
|
|
373
|
+
if (query.match(/\n([ \t]+)[^\n]+$/)) {
|
|
374
|
+
const prefix = RegExp.$1;
|
|
375
|
+
query = query.replace(new RegExp("^" + prefix, "gm"), "").trimEnd();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return new RestRequest<TAssertShape>(
|
|
379
|
+
this._options,
|
|
380
|
+
"POST",
|
|
381
|
+
"",
|
|
382
|
+
new Headers({ "Content-Type": "application/json" }),
|
|
383
|
+
JSON.stringify({ variables, query }), // variables first - for truncation in logs,
|
|
384
|
+
origShape
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Sends a plain request (with no body, like GET or DELETE).
|
|
390
|
+
*/
|
|
391
|
+
private _noBodyRequest(
|
|
392
|
+
path: string,
|
|
393
|
+
args: Partial<Record<string, string | number | string[]>> = {},
|
|
394
|
+
method: "GET" | "DELETE",
|
|
395
|
+
accept: string = "application/json"
|
|
396
|
+
) {
|
|
397
|
+
const origShape = simpleShape(path, args);
|
|
398
|
+
[path, args] = substituteParams(path, args);
|
|
399
|
+
const searchParams = new URLSearchParams();
|
|
400
|
+
for (const [k, v] of Object.entries(args)) {
|
|
401
|
+
if (Array.isArray(v)) {
|
|
402
|
+
for (const element of v) {
|
|
403
|
+
searchParams.append(k, element.toString());
|
|
404
|
+
}
|
|
405
|
+
} else if (v !== undefined) {
|
|
406
|
+
searchParams.append(k, v.toString());
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const suffix = searchParams.toString();
|
|
411
|
+
if (suffix !== "") {
|
|
412
|
+
path += (path.includes("?") ? "&" : "?") + suffix;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return new RestRequest(
|
|
416
|
+
this._options,
|
|
417
|
+
method,
|
|
418
|
+
path,
|
|
419
|
+
new Headers({ Accept: accept }),
|
|
420
|
+
"",
|
|
421
|
+
origShape
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* @ignore
|
|
428
|
+
* Calls token(null), then runs body() passing the result there. If we get a
|
|
429
|
+
* RestTokenInvalidError exception, call token() with this error as a parameter
|
|
430
|
+
* and then passes the response to body() again (kinda retry with a new token).
|
|
431
|
+
*/
|
|
432
|
+
export async function tokenRetryStrategy<TData>(
|
|
433
|
+
token: TokenGetter<TData>,
|
|
434
|
+
body: (tokenData: TData) => Promise<RestResponse>
|
|
435
|
+
) {
|
|
436
|
+
let tokenData = await token(null);
|
|
437
|
+
try {
|
|
438
|
+
return await body(tokenData);
|
|
439
|
+
} catch (e: any) {
|
|
440
|
+
const supportsRetry = token.length > 0; // args count
|
|
441
|
+
if (e instanceof RestTokenInvalidError && supportsRetry) {
|
|
442
|
+
tokenData = await token(e);
|
|
443
|
+
try {
|
|
444
|
+
return await body(tokenData);
|
|
445
|
+
} catch (e: any) {
|
|
446
|
+
if (e instanceof RestTokenInvalidError) {
|
|
447
|
+
e.message = `(still failed, even with updated token) ${e.message}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
throw e;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
throw e;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Returns a simple "shape" of a request useful for e.g. grouping by in logger
|
|
460
|
+
* to figure out, which requests are more frequent.
|
|
461
|
+
* - reflects args names and not values
|
|
462
|
+
* - wipes the domain
|
|
463
|
+
* - removes query string parameter values
|
|
464
|
+
*/
|
|
465
|
+
function simpleShape(path: string, args?: any) {
|
|
466
|
+
path = path.replace(/^\w+:\/\/[^/]+/, "").replace(/=[^&]+/g, "=");
|
|
467
|
+
|
|
468
|
+
const argsInPath: string[] = [];
|
|
469
|
+
const regex = /:([a-z0-9_]+)/gi;
|
|
470
|
+
while (true) {
|
|
471
|
+
const match = regex.exec(path);
|
|
472
|
+
if (!match) {
|
|
473
|
+
break;
|
|
474
|
+
} else {
|
|
475
|
+
argsInPath.push(match[1]);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const keys =
|
|
480
|
+
args && typeof args === "object"
|
|
481
|
+
? Object.keys(args)
|
|
482
|
+
// Filter out args that are already mentioned in the path, e.g.
|
|
483
|
+
// /pages/:pageID/blocks.
|
|
484
|
+
.filter((arg) => !argsInPath.includes(arg))
|
|
485
|
+
.sort()
|
|
486
|
+
.join(",")
|
|
487
|
+
: "";
|
|
488
|
+
|
|
489
|
+
return path + (keys !== "" ? `:${keys}` : "");
|
|
490
|
+
}
|
|
@@ -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
|
+
};
|