gaxios
Version:
A simple common HTTP client specifically for Google APIs and services.
553 lines • 22 kB
JavaScript
// Copyright 2018 Google LLC
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Gaxios = void 0;
const extend_1 = __importDefault(require("extend"));
const https_1 = require("https");
const common_js_1 = require("./common.js");
const retry_js_1 = require("./retry.js");
const stream_1 = require("stream");
const interceptor_js_1 = require("./interceptor.js");
const randomUUID = async () => globalThis.crypto?.randomUUID() || (await import('crypto')).randomUUID();
class Gaxios {
agentCache = new Map();
/**
* Default HTTP options that will be used for every HTTP request.
*/
defaults;
/**
* Interceptors
*/
interceptors;
/**
* The Gaxios class is responsible for making HTTP requests.
* @param defaults The default set of options to be used for this instance.
*/
constructor(defaults) {
this.defaults = defaults || {};
this.interceptors = {
request: new interceptor_js_1.GaxiosInterceptorManager(),
response: new interceptor_js_1.GaxiosInterceptorManager(),
};
}
/**
* A {@link fetch `fetch`} compliant API for {@link Gaxios}.
*
* @remarks
*
* This is useful as a drop-in replacement for `fetch` API usage.
*
* @example
*
* ```ts
* const gaxios = new Gaxios();
* const myFetch: typeof fetch = (...args) => gaxios.fetch(...args);
* await myFetch('https://example.com');
* ```
*
* @param args `fetch` API or `Gaxios#request` parameters
* @returns the {@link Response} with Gaxios-added properties
*/
fetch(...args) {
// Up to 2 parameters in either overload
const input = args[0];
const init = args[1];
let url = undefined;
const headers = new Headers();
// prepare URL
if (typeof input === 'string') {
url = new URL(input);
}
else if (input instanceof URL) {
url = input;
}
else if (input && input.url) {
url = new URL(input.url);
}
// prepare headers
if (input && typeof input === 'object' && 'headers' in input) {
_a.mergeHeaders(headers, input.headers);
}
if (init) {
_a.mergeHeaders(headers, new Headers(init.headers));
}
// prepare request
if (typeof input === 'object' && !(input instanceof URL)) {
// input must have been a non-URL object
return this.request({ ...init, ...input, headers, url });
}
else {
// input must have been a string or URL
return this.request({ ...init, headers, url });
}
}
/**
* Perform an HTTP request with the given options.
* @param opts Set of HTTP options that will be used for this HTTP request.
*/
async request(opts = {}) {
let prepared = await this.#prepareRequest(opts);
prepared = await this.#applyRequestInterceptors(prepared);
return this.#applyResponseInterceptors(this._request(prepared));
}
async _defaultAdapter(config) {
const fetchImpl = config.fetchImplementation ||
this.defaults.fetchImplementation ||
(await _a.#getFetch());
// node-fetch v3 warns when `data` is present
// https://github.com/node-fetch/node-fetch/issues/1000
const preparedOpts = { ...config };
delete preparedOpts.data;
const res = (await fetchImpl(config.url, preparedOpts));
const data = await this.getResponseData(config, res);
if (!Object.getOwnPropertyDescriptor(res, 'data')?.configurable) {
// Work-around for `node-fetch` v3 as accessing `data` would otherwise throw
Object.defineProperties(res, {
data: {
configurable: true,
writable: true,
enumerable: true,
value: data,
},
});
}
// Keep object as an instance of `Response`
return Object.assign(res, { config, data });
}
/**
* Internal, retryable version of the `request` method.
* @param opts Set of HTTP options that will be used for this HTTP request.
*/
async _request(opts) {
try {
let translatedResponse;
if (opts.adapter) {
translatedResponse = await opts.adapter(opts, this._defaultAdapter.bind(this));
}
else {
translatedResponse = await this._defaultAdapter(opts);
}
if (!opts.validateStatus(translatedResponse.status)) {
if (opts.responseType === 'stream') {
const response = [];
for await (const chunk of opts.data) {
response.push(chunk);
}
translatedResponse.data = response;
}
const errorInfo = common_js_1.GaxiosError.extractAPIErrorFromResponse(translatedResponse, `Request failed with status code ${translatedResponse.status}`);
throw new common_js_1.GaxiosError(errorInfo?.message, opts, translatedResponse, errorInfo);
}
return translatedResponse;
}
catch (e) {
let err;
if (e instanceof common_js_1.GaxiosError) {
err = e;
}
else if (e instanceof Error) {
err = new common_js_1.GaxiosError(e.message, opts, undefined, e);
}
else {
err = new common_js_1.GaxiosError('Unexpected Gaxios Error', opts, undefined, e);
}
const { shouldRetry, config } = await (0, retry_js_1.getRetryConfig)(err);
if (shouldRetry && config) {
err.config.retryConfig.currentRetryAttempt =
config.retryConfig.currentRetryAttempt;
// The error's config could be redacted - therefore we only want to
// copy the retry state over to the existing config
opts.retryConfig = err.config?.retryConfig;
// re-prepare timeout for the next request
this.#appendTimeoutToSignal(opts);
return this._request(opts);
}
if (opts.errorRedactor) {
opts.errorRedactor(err);
}
throw err;
}
}
async getResponseData(opts, res) {
if (opts.maxContentLength &&
res.headers.has('content-length') &&
opts.maxContentLength <
Number.parseInt(res.headers?.get('content-length') || '')) {
throw new common_js_1.GaxiosError("Response's `Content-Length` is over the limit.", opts, Object.assign(res, { config: opts }));
}
switch (opts.responseType) {
case 'stream':
return res.body;
case 'json':
return res.json();
case 'arraybuffer':
return res.arrayBuffer();
case 'blob':
return res.blob();
case 'text':
return res.text();
default:
return this.getResponseDataFromContentType(res);
}
}
#urlMayUseProxy(url, noProxy = []) {
const candidate = new URL(url);
const noProxyList = [...noProxy];
const noProxyEnvList = (process.env.NO_PROXY ?? process.env.no_proxy)?.split(',') || [];
for (const rule of noProxyEnvList) {
noProxyList.push(rule.trim());
}
for (const rule of noProxyList) {
// Match regex
if (rule instanceof RegExp) {
if (rule.test(candidate.toString())) {
return false;
}
}
// Match URL
else if (rule instanceof URL) {
if (rule.origin === candidate.origin) {
return false;
}
}
// Match string regex
else if (rule.startsWith('*.') || rule.startsWith('.')) {
const cleanedRule = rule.replace(/^\*\./, '.');
if (candidate.hostname.endsWith(cleanedRule)) {
return false;
}
}
// Basic string match
else if (rule === candidate.origin ||
rule === candidate.hostname ||
rule === candidate.href) {
return false;
}
}
return true;
}
/**
* Applies the request interceptors. The request interceptors are applied after the
* call to prepareRequest is completed.
*
* @param {GaxiosOptionsPrepared} options The current set of options.
*
* @returns {Promise<GaxiosOptionsPrepared>} Promise that resolves to the set of options or response after interceptors are applied.
*/
async #applyRequestInterceptors(options) {
let promiseChain = Promise.resolve(options);
for (const interceptor of this.interceptors.request.values()) {
if (interceptor) {
promiseChain = promiseChain.then(interceptor.resolved, interceptor.rejected);
}
}
return promiseChain;
}
/**
* Applies the response interceptors. The response interceptors are applied after the
* call to request is made.
*
* @param {GaxiosOptionsPrepared} options The current set of options.
*
* @returns {Promise<GaxiosOptionsPrepared>} Promise that resolves to the set of options or response after interceptors are applied.
*/
async #applyResponseInterceptors(response) {
let promiseChain = Promise.resolve(response);
for (const interceptor of this.interceptors.response.values()) {
if (interceptor) {
promiseChain = promiseChain.then(interceptor.resolved, interceptor.rejected);
}
}
return promiseChain;
}
/**
* Validates the options, merges them with defaults, and prepare request.
*
* @param options The original options passed from the client.
* @returns Prepared options, ready to make a request
*/
async #prepareRequest(options) {
// Prepare Headers - copy in order to not mutate the original objects
const preparedHeaders = new Headers(this.defaults.headers);
_a.mergeHeaders(preparedHeaders, options.headers);
// Merge options
const opts = (0, extend_1.default)(true, {}, this.defaults, options);
if (!opts.url) {
throw new Error('URL is required.');
}
if (opts.baseURL) {
opts.url = new URL(opts.url, opts.baseURL);
}
// don't modify the properties of a default or provided URL
opts.url = new URL(opts.url);
if (opts.params) {
if (opts.paramsSerializer) {
let additionalQueryParams = opts.paramsSerializer(opts.params);
if (additionalQueryParams.startsWith('?')) {
additionalQueryParams = additionalQueryParams.slice(1);
}
const prefix = opts.url.toString().includes('?') ? '&' : '?';
opts.url = opts.url + prefix + additionalQueryParams;
}
else {
const url = opts.url instanceof URL ? opts.url : new URL(opts.url);
for (const [key, value] of new URLSearchParams(opts.params)) {
url.searchParams.append(key, value);
}
opts.url = url;
}
}
if (typeof options.maxContentLength === 'number') {
opts.size = options.maxContentLength;
}
if (typeof options.maxRedirects === 'number') {
opts.follow = options.maxRedirects;
}
const shouldDirectlyPassData = typeof opts.data === 'string' ||
opts.data instanceof ArrayBuffer ||
opts.data instanceof Blob ||
// Node 18 does not have a global `File` object
(globalThis.File && opts.data instanceof File) ||
opts.data instanceof FormData ||
opts.data instanceof stream_1.Readable ||
opts.data instanceof ReadableStream ||
opts.data instanceof String ||
opts.data instanceof URLSearchParams ||
ArrayBuffer.isView(opts.data) || // `Buffer` (Node.js), `DataView`, `TypedArray`
/**
* @deprecated `node-fetch` or another third-party's request types
*/
['Blob', 'File', 'FormData'].includes(opts.data?.constructor?.name || '');
if (opts.multipart?.length) {
const boundary = await randomUUID();
preparedHeaders.set('content-type', `multipart/related; boundary=${boundary}`);
opts.body = stream_1.Readable.from(this.getMultipartRequest(opts.multipart, boundary));
}
else if (shouldDirectlyPassData) {
opts.body = opts.data;
}
else if (typeof opts.data === 'object') {
if (preparedHeaders.get('Content-Type') ===
'application/x-www-form-urlencoded') {
// If www-form-urlencoded content type has been set, but data is
// provided as an object, serialize the content
opts.body = opts.paramsSerializer
? opts.paramsSerializer(opts.data)
: new URLSearchParams(opts.data);
}
else {
if (!preparedHeaders.has('content-type')) {
preparedHeaders.set('content-type', 'application/json');
}
opts.body = JSON.stringify(opts.data);
}
}
else if (opts.data) {
opts.body = opts.data;
}
opts.validateStatus = opts.validateStatus || this.validateStatus;
opts.responseType = opts.responseType || 'unknown';
if (!preparedHeaders.has('accept') && opts.responseType === 'json') {
preparedHeaders.set('accept', 'application/json');
}
const proxy = opts.proxy ||
process?.env?.HTTPS_PROXY ||
process?.env?.https_proxy ||
process?.env?.HTTP_PROXY ||
process?.env?.http_proxy;
if (opts.agent) {
// don't do any of the following options - use the user-provided agent.
}
else if (proxy && this.#urlMayUseProxy(opts.url, opts.noProxy)) {
const HttpsProxyAgent = await _a.#getProxyAgent();
if (this.agentCache.has(proxy)) {
opts.agent = this.agentCache.get(proxy);
}
else {
opts.agent = new HttpsProxyAgent(proxy, {
cert: opts.cert,
key: opts.key,
});
this.agentCache.set(proxy, opts.agent);
}
}
else if (opts.cert && opts.key) {
// Configure client for mTLS
if (this.agentCache.has(opts.key)) {
opts.agent = this.agentCache.get(opts.key);
}
else {
opts.agent = new https_1.Agent({
cert: opts.cert,
key: opts.key,
});
this.agentCache.set(opts.key, opts.agent);
}
}
if (typeof opts.errorRedactor !== 'function' &&
opts.errorRedactor !== false) {
opts.errorRedactor = common_js_1.defaultErrorRedactor;
}
if (opts.body && !('duplex' in opts)) {
/**
* required for Node.js and the type isn't available today
* @link https://github.com/nodejs/node/issues/46221
* @link https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1483
*/
opts.duplex = 'half';
}
this.#appendTimeoutToSignal(opts);
return Object.assign(opts, {
headers: preparedHeaders,
url: opts.url instanceof URL ? opts.url : new URL(opts.url),
});
}
#appendTimeoutToSignal(opts) {
if (opts.timeout) {
const timeoutSignal = AbortSignal.timeout(opts.timeout);
if (opts.signal && !opts.signal.aborted) {
opts.signal = AbortSignal.any([opts.signal, timeoutSignal]);
}
else {
opts.signal = timeoutSignal;
}
}
}
/**
* By default, throw for any non-2xx status code
* @param status status code from the HTTP response
*/
validateStatus(status) {
return status >= 200 && status < 300;
}
/**
* Attempts to parse a response by looking at the Content-Type header.
* @param {Response} response the HTTP response.
* @returns a promise that resolves to the response data.
*/
async getResponseDataFromContentType(response) {
let contentType = response.headers.get('Content-Type');
if (contentType === null) {
// Maintain existing functionality by calling text()
return response.text();
}
contentType = contentType.toLowerCase();
if (contentType.includes('application/json')) {
let data = await response.text();
try {
data = JSON.parse(data);
}
catch {
// continue
}
return data;
}
else if (contentType.match(/^text\//)) {
return response.text();
}
else {
// If the content type is something not easily handled, just return the raw data (blob)
return response.blob();
}
}
/**
* Creates an async generator that yields the pieces of a multipart/related request body.
* This implementation follows the spec: https://www.ietf.org/rfc/rfc2387.txt. However, recursive
* multipart/related requests are not currently supported.
*
* @param {GaxioMultipartOptions[]} multipartOptions the pieces to turn into a multipart/related body.
* @param {string} boundary the boundary string to be placed between each part.
*/
async *getMultipartRequest(multipartOptions, boundary) {
const finale = `--${boundary}--`;
for (const currentPart of multipartOptions) {
const partContentType = currentPart.headers.get('Content-Type') || 'application/octet-stream';
const preamble = `--${boundary}\r\nContent-Type: ${partContentType}\r\n\r\n`;
yield preamble;
if (typeof currentPart.content === 'string') {
yield currentPart.content;
}
else {
yield* currentPart.content;
}
yield '\r\n';
}
yield finale;
}
/**
* A cache for the lazily-loaded proxy agent.
*
* Should use {@link Gaxios[#getProxyAgent]} to retrieve.
*/
// using `import` to dynamically import the types here
static #proxyAgent;
/**
* A cache for the lazily-loaded fetch library.
*
* Should use {@link Gaxios[#getFetch]} to retrieve.
*/
//
static #fetch;
/**
* Imports, caches, and returns a proxy agent - if not already imported
*
* @returns A proxy agent
*/
static async #getProxyAgent() {
this.#proxyAgent ||= (await import('https-proxy-agent')).HttpsProxyAgent;
return this.#proxyAgent;
}
static async #getFetch() {
const hasWindow = typeof window !== 'undefined' && !!window;
this.#fetch ||= hasWindow
? window.fetch
: (await import('node-fetch')).default;
return this.#fetch;
}
/**
* Merges headers.
* If the base headers do not exist a new `Headers` object will be returned.
*
* @remarks
*
* Using this utility can be helpful when the headers are not known to exist:
* - if they exist as `Headers`, that instance will be used
* - it improves performance and allows users to use their existing references to their `Headers`
* - if they exist in another form (`HeadersInit`), they will be used to create a new `Headers` object
* - if the base headers do not exist a new `Headers` object will be created
*
* @param base headers to append/overwrite to
* @param append headers to append/overwrite with
* @returns the base headers instance with merged `Headers`
*/
static mergeHeaders(base, ...append) {
base = base instanceof Headers ? base : new Headers(base);
for (const headers of append) {
const add = headers instanceof Headers ? headers : new Headers(headers);
add.forEach((value, key) => {
// set-cookie is the only header that would repeat.
// A bit of background: https://developer.mozilla.org/en-US/docs/Web/API/Headers/getSetCookie
key === 'set-cookie' ? base.append(key, value) : base.set(key, value);
});
}
return base;
}
}
exports.Gaxios = Gaxios;
_a = Gaxios;
//# sourceMappingURL=gaxios.js.map
;