UNPKG

@clickup/rest-client

Version:

A syntax sugar tool around Node fetch() API, tailored to work with TypeScript and response validators

362 lines 16.2 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const dns_1 = __importDefault(require("dns")); const util_1 = require("util"); const fast_typescript_memoize_1 = require("fast-typescript-memoize"); const ipaddr_js_1 = require("ipaddr.js"); const random_1 = __importDefault(require("lodash/random")); const node_fetch_1 = require("node-fetch"); const RestContentSizeOverLimitError_1 = __importDefault(require("./errors/RestContentSizeOverLimitError")); const RestError_1 = __importDefault(require("./errors/RestError")); const RestTimeoutError_1 = __importDefault(require("./errors/RestTimeoutError")); const calcRetryDelay_1 = __importDefault(require("./internal/calcRetryDelay")); const inspectPossibleJSON_1 = __importDefault(require("./internal/inspectPossibleJSON")); const RestFetchReader_1 = __importDefault(require("./internal/RestFetchReader")); const throwIfErrorResponse_1 = __importDefault(require("./internal/throwIfErrorResponse")); const toFloatMs_1 = __importDefault(require("./internal/toFloatMs")); const RestResponse_1 = __importDefault(require("./RestResponse")); const RestStream_1 = __importDefault(require("./RestStream")); const MAX_DEBUG_LEN = 1024 * 100; /** * Type TAssertShape allows to limit json()'s assert callbacks to only those * which return an object compatible with TAssertShape. */ class RestRequest { constructor(options, method, url, headers, body, shape) { this.method = method; this.url = url; this.headers = headers; this.body = body; this.shape = shape; this.options = { ...options }; } /** * Modifies the request by adding a custom HTTP header. */ setHeader(name, value) { this.headers.append(name, value); return this; } /** * Modifies the request by adding a custom request option. */ setOptions(options) { for (const [k, v] of Object.entries(options)) { this.options[k] = v; } return this; } /** * Forces RestClient to debug-output the request and response to console. * Never use in production. */ setDebug(flag = true) { this.options.isDebug = flag; return this; } /** * Sends the request and reads the response a JSON. In absolute most of the * cases, this method is used to reach API responses. The assert callback * (typically generated by typescript-is) is intentionally made mandatory to * not let people to do anti-patterns. */ async json(assert, ...checkers) { const res = await this.response(); // Support Superstruct in a duck-typing way. if (typeof assert !== "function") { if ("mask" in assert) { assert = assert.mask.bind(assert); } else if ("$assert" in assert) { assert = assert.$assert.bind(assert); } } const json = assert(res.json); for (const checker of checkers) { const error = checker(json, res); if (error !== false) { throw error; } } return json; } /** * Sends the request and returns plaintext response. */ async text() { const res = await this.response(); return res.text; } /** * Returns the entire RestResponse object with response status and headers * information in it. Try to minimize usage of this method, because it doesn't * make any assumptions on the response structure. */ async response() { const stream = await this.stream( // By passing Number.MAX_SAFE_INTEGER to stream(), we ensure that the // entire data will be preloaded, or the loading will fail due to // throwIfResIsBigger limitation, whichever will happen faster. Number.MAX_SAFE_INTEGER); await stream.close(); return stream.res; } /** * Sends the requests and returns RestStream object. You MUST iterate over * this object entirely (or call its return() method), otherwise the * connection will remain dangling. */ async stream(preloadChars = 128 * 1024) { const finalAttempt = Math.max(this.options.retries + 1, 1); let retryDelayMs = Math.max(this.options.retryDelayFirstMs, 10); for (let attempt = 1; attempt <= finalAttempt; attempt++) { let timeStart = process.hrtime(); let res = null; try { // Middlewares work with RestResponse (can alter it) and not with // RestStream intentionally. So the goal here is to create a // RestResponse object, and then later derive a RestStream from it. let reader = null; res = await runMiddlewares(this, this.options.middlewares, async (req) => { reader = null; try { timeStart = process.hrtime(); res = null; try { const fetchReq = await req._createFetchRequest(); reader = req._createFetchReader(fetchReq); await reader.preload(preloadChars); } finally { res = reader ? req._createRestResponse(reader) : new RestResponse_1.default(req, null, 0, new node_fetch_1.Headers(), "", false); } (0, throwIfErrorResponse_1.default)(req.options, res); req._logResponse({ attempt, req, res, exception: null, timestamp: Date.now(), elapsed: (0, toFloatMs_1.default)(process.hrtime(timeStart)), isFinalAttempt: attempt === finalAttempt, privateDataInResponse: req.options.privateDataInResponse, comment: "", }); return res; } catch (e) { await (reader === null || reader === void 0 ? void 0 : reader.close()); throw e; } }); // The only place where we return the response. Otherwise we retry or // throw an exception. return new RestStream_1.default(res, reader); } catch (error) { this._logResponse({ attempt, req: this, res, exception: error, timestamp: Date.now(), elapsed: (0, toFloatMs_1.default)(process.hrtime(timeStart)), isFinalAttempt: attempt === finalAttempt, privateDataInResponse: this.options.privateDataInResponse, comment: "", }); if (res === null) { // An error in internal function or middleware; this must not happen. throw error; } if (attempt === finalAttempt) { // Last retry attempt; always throw. throw error; } const newRetryDelay = (0, calcRetryDelay_1.default)(error, this.options, res, retryDelayMs); if (newRetryDelay === "no_retry") { throw error; } retryDelayMs = newRetryDelay; } const delayStart = process.hrtime(); retryDelayMs *= (0, random_1.default)(1 - this.options.retryDelayJitter, 1 + this.options.retryDelayJitter, true); await this.options.heartbeater.delay(retryDelayMs); this._logResponse({ attempt, req: this, res: "backoff_delay", exception: null, timestamp: Date.now(), elapsed: (0, toFloatMs_1.default)(process.hrtime(delayStart)), isFinalAttempt: false, privateDataInResponse: this.options.privateDataInResponse, comment: "", }); retryDelayMs *= this.options.retryDelayFactor; retryDelayMs = Math.min(this.options.retryDelayMaxMs, retryDelayMs); } throw Error("BUG: should never happen"); } /** * We can actually only create RequestInit. We can't create an instance of * node-fetch.Request object since it doesn't allow injection of * AbortController later, and we don't want to deal with AbortController here. */ async _createFetchRequest() { const url = new URL(this.url); if (!["http:", "https:"].includes(url.protocol)) { throw new RestError_1.default(`Unsupported protocol: ${url.protocol}`); } // TODO: rework DNS resolution to use a custom Agent. We'd be able to // support safe redirects then. // Resolve IP address, check it for being a public IP address and substitute // it in the URL; the hostname will be passed separately via Host header. const hostname = url.hostname; const headers = new node_fetch_1.Headers(this.headers); const addr = await (0, util_1.promisify)(dns_1.default.lookup)(hostname, { family: this.options.family, }); let redirectMode = "follow"; if (!this.options.allowInternalIPs) { const range = (0, ipaddr_js_1.parse)(addr.address).range(); // External requests are returned as "unicast" by ipaddr.js. const isInternal = range !== "unicast"; if (isInternal) { throw new RestError_1.default(`Domain ${hostname} resolves to a non-public (${range}) IP address ${addr.address}`); } url.hostname = addr.address; if (!headers.get("host")) { headers.set("host", hostname); } // ATTENTION: don't turn on redirects, it's a security breach when using // with allowInternalIPs=false which is default! redirectMode = "error"; } const hasKeepAlive = this.options.keepAlive.timeoutMs > 0; if (hasKeepAlive) { headers.append("connection", "Keep-Alive"); } // Use lazily created/cached per-RestClient Agent instance to utilize HTTP // persistent connections and save on HTTPS connection re-establishment. const agent = (url.protocol === "https:" ? this.options.agents.https.bind(this.options.agents) : this.options.agents.http.bind(this.options.agents))({ keepAlive: hasKeepAlive, timeout: hasKeepAlive ? this.options.keepAlive.timeoutMs : undefined, maxSockets: this.options.keepAlive.maxSockets, rejectUnauthorized: this.options.allowInternalIPs ? false : undefined, family: this.options.family, }); return { url: url.toString(), method: this.method, headers, body: this.body || undefined, timeout: this.options.timeoutMs, redirect: redirectMode, agent, }; } /** * Creates an instance of RestFetchReader. */ _createFetchReader(req) { return new RestFetchReader_1.default(req.url, req, { timeoutMs: this.options.timeoutMs, heartbeat: async () => this.options.heartbeater.heartbeat(), onTimeout: (reader) => { throw new RestTimeoutError_1.default(`Timed out while reading response body (${this.options.timeoutMs} ms)`, this._createRestResponse(reader)); }, onAfterRead: (reader) => { if (this.options.throwIfResIsBigger && reader.charsRead > this.options.throwIfResIsBigger) { throw new RestContentSizeOverLimitError_1.default(`Content size is over limit of ${this.options.throwIfResIsBigger} characters`, this._createRestResponse(reader)); } }, responseEncoding: this.options.responseEncoding, }); } /** * Creates a RestResponse from a RestFetchReader. Assumes that * RestFetchReader.preload() has already been called. */ _createRestResponse(reader) { return new RestResponse_1.default(this, reader.agent, reader.status, reader.headers, reader.textFetched, reader.textIsPartial); } /** * Logs a response event, an error event or a backoff event. If RestResponse * is not yet known (e.g. an exception happened in a DNS resolution), res must * be passed as null. */ _logResponse(event) { this.options.logger(event); // Debug-logging to console? if (this.options.isDebug || (process.env["NODE_ENV"] === "development" && event.res === "backoff_delay")) { let reqMessage = `${this.method} ${this.url}\n` + Object.entries(this.headers.raw()) .map(([k, vs]) => vs.map((v) => `${k}: ${v}`)) .join("\n"); if (this.body) { reqMessage += "\n" + (0, inspectPossibleJSON_1.default)(this.headers, this.body, MAX_DEBUG_LEN); } let resMessage = ""; if (event.res === "backoff_delay") { resMessage = "Previous request failed, backoff delay elapsed, retrying attempt " + (event.attempt + 1) + "..."; } else if (event.res) { resMessage = `HTTP ${event.res.status} ` + `(took ${Math.round(event.elapsed)} ms)`; if (event.res.text) { resMessage += "\n" + (0, inspectPossibleJSON_1.default)(event.res.headers, event.res.text, MAX_DEBUG_LEN); } } else if (event.exception) { resMessage = "" + event.exception; } // eslint-disable-next-line no-console console.log(reqMessage.replace(/^/gm, "+++ ")); // eslint-disable-next-line no-console console.log(resMessage.replace(/^/gm, "=== ") + "\n"); } } } exports.default = RestRequest; __decorate([ (0, fast_typescript_memoize_1.Memoize)() ], RestRequest.prototype, "response", null); /** * Runs the middlewares chain of responsibility. Each middleware receives a * mutable RestRequest object and next() callback which, when called, triggers * execution of the remaining middlewares in the chain. This allows a middleware * to not only modify the request object, but also alter the response received * from the subsequent middlewares. */ async function runMiddlewares(req, middlewares, last) { if (middlewares.length > 0) { const [head, ...tail] = middlewares; return head(req, async (req) => runMiddlewares(req, tail, last)); } return last(req); } //# sourceMappingURL=RestRequest.js.map