From 33702e6d3d470957bf40097d6235d13a75d14cb1 Mon Sep 17 00:00:00 2001 From: Daniel Bankhead Date: Fri, 25 Oct 2024 10:02:55 -0700 Subject: [PATCH] feat!: Upgrade to `node-fetch` v3 (#617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat!: Upgrade to `node-fetch` v3 * refactor: Use native `FormData` in testing * feat!: Improve spec compliance * test(temp): Run 18+ * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * feat: Improve types, streamline, and standardize * test: Adopt `Headers` type from merge * feat: Introduce `GaxiosOptionsPrepared` for improved type guarantees * feat: Ensure `GaxiosOptionsPrepared.url` is always a `URL` * refactor: Clean-up Types & Resolve lint warnings * refactor: streamline `.data` for `Response` * docs: Simplify example * refactor: streamline, no error case * feat: Basic `GET` Support for `Request` objects `node-fetch` does not yet support webstreams, which is required for `.body` https://github.com/node-fetch/node-fetch/issues/387 * test: remove `.only` * refactor: simplify `httpMethodsToRetry` * chore: update `nock` * test: cleanup * fix: `File` in Node 18 * docs: clarification for node-fetch `.data` * chore: fix webpack for node-fetch v3 * docs: clarifications * fix: Types for Node.js-only environments --------- Co-authored-by: Owl Bot --- README.md | 57 ++- browser-test/test.browser.ts | 4 +- package.json | 10 +- src/common.ts | 293 +++++++------- src/gaxios.ts | 357 ++++++++++-------- src/index.ts | 2 +- src/interceptor.ts | 8 +- src/retry.ts | 16 +- system-test/fixtures/sample/webpack.config.js | 19 +- test/test.getch.ts | 244 +++++++----- test/test.retry.ts | 16 +- tsconfig.json | 6 +- webpack-tests.config.js | 1 - webpack.config.js | 1 - 14 files changed, 582 insertions(+), 452 deletions(-) diff --git a/README.md b/README.md index 7cf8b6b8..0114e2f8 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,7 @@ $ npm install gaxios ```js const {request} = require('gaxios'); -const res = await request({ - url: 'https://www.googleapis.com/discovery/v1/apis/', -}); +const res = await request('https://www.googleapis.com/discovery/v1/apis/'); ``` ## Setting Defaults @@ -53,16 +51,27 @@ interface GaxiosOptions = { baseURL: 'https://example.com'; // The HTTP methods to be sent with the request. - headers: { 'some': 'header' }, - - // The data to send in the body of the request. Data objects will be - // serialized as JSON. + headers: { 'some': 'header' } || new Headers(), + + // The data to send in the body of the request. Objects will be serialized as JSON + // except for: + // - `ArrayBuffer` + // - `Blob` + // - `Buffer` (Node.js) + // - `DataView` + // - `File` + // - `FormData` + // - `ReadableStream` + // - `stream.Readable` (Node.js) + // - strings + // - `TypedArray` (e.g. `Uint8Array`, `BigInt64Array`) + // - `URLSearchParams` + // - all other objects where: + // - headers.get('Content-Type') === 'application/x-www-form-urlencoded' (as they will be serialized as `URLSearchParams`) // - // Note: if you would like to provide a Content-Type header other than - // application/json you you must provide a string or readable stream, rather - // than an object: - // data: JSON.stringify({some: 'data'}) - // data: fs.readFile('./some-data.jpeg') + // Here are a few examples that would prevent setting `Content-Type: application/json` by default: + // - data: JSON.stringify({some: 'data'}) // a `string` + // - data: fs.readFile('./some-data.jpeg') // a `stream.Readable` data: { some: 'data' }, @@ -71,23 +80,12 @@ interface GaxiosOptions = { // Defaults to `0`, which is the same as unset. maxContentLength: 2000, - // The max number of HTTP redirects to follow. - // Defaults to 100. - maxRedirects: 100, - - // The querystring parameters that will be encoded using `qs` and + // The query parameters that will be encoded using `URLSearchParams` and // appended to the url params: { querystring: 'parameters' }, - // By default, we use the `querystring` package in node core to serialize - // querystring parameters. You can override that and provide your - // own implementation. - paramsSerializer: (params) => { - return qs.stringify(params); - }, - // The timeout for the HTTP request in milliseconds. Defaults to 0. timeout: 1000, @@ -105,6 +103,8 @@ interface GaxiosOptions = { // The expected return type of the request. Options are: // json | stream | blob | arraybuffer | text | unknown // Defaults to `unknown`. + // If the `fetchImplementation` is native `fetch`, the + // stream is a `ReadableStream`, otherwise `readable.Stream` responseType: 'unknown', // The node.js http agent to use for the request. @@ -114,9 +114,9 @@ interface GaxiosOptions = { // status code. Defaults to (>= 200 && < 300) validateStatus: (status: number) => true, - // Implementation of `fetch` to use when making the API call. By default, - // will use the browser context if available, and fall back to `node-fetch` - // in node.js otherwise. + /** + * Implementation of `fetch` to use when making the API call. Will use `fetch` by default. + */ fetchImplementation?: typeof fetch; // Configuration for retrying of requests. @@ -151,8 +151,7 @@ interface GaxiosOptions = { // Enables default configuration for retries. retry: boolean, - // Cancelling a request requires the `abort-controller` library. - // See https://github.com/bitinn/node-fetch#request-cancellation-with-abortsignal + // Enables aborting via AbortController signal?: AbortSignal /** diff --git a/browser-test/test.browser.ts b/browser-test/test.browser.ts index 06d926e0..41b2ccb3 100644 --- a/browser-test/test.browser.ts +++ b/browser-test/test.browser.ts @@ -14,7 +14,6 @@ import assert from 'assert'; import {describe, it} from 'mocha'; import {request} from '../src/index'; -import * as uuid from 'uuid'; const port = 7172; // should match the port defined in `webserver.ts` describe('💻 browser tests', () => { @@ -53,7 +52,8 @@ describe('💻 browser tests', () => { body: 'hello world!', }, ]; - const boundary = uuid.v4(); + const boundary = + globalThis?.crypto.randomUUID() || (await import('crypto')).randomUUID(); const finale = `--${boundary}--`; headers['Content-Type'] = `multipart/related; boundary=${boundary}`; diff --git a/package.json b/package.json index 76721467..83834028 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,8 @@ "@types/mv": "^2.1.0", "@types/ncp": "^2.0.1", "@types/node": "^20.0.0", - "@types/node-fetch": "^2.5.7", "@types/sinon": "^17.0.0", "@types/tmp": "0.2.6", - "@types/uuid": "^10.0.0", - "abort-controller": "^3.0.0", "assert": "^2.0.0", "browserify": "^17.0.0", "c8": "^8.0.0", @@ -58,7 +55,6 @@ "cors": "^2.8.5", "execa": "^5.0.0", "express": "^4.16.4", - "form-data": "^4.0.0", "gts": "^5.0.0", "is-docker": "^2.0.0", "jsdoc": "^4.0.0", @@ -77,7 +73,7 @@ "multiparty": "^4.2.1", "mv": "^2.1.1", "ncp": "^2.0.0", - "nock": "^13.0.0", + "nock": "^14.0.0-beta.13", "null-loader": "^4.0.0", "puppeteer": "^19.0.0", "sinon": "^17.0.0", @@ -91,8 +87,6 @@ "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" + "node-fetch": "^3.3.2" } } diff --git a/src/common.ts b/src/common.ts index b1bedf6d..911a0505 100644 --- a/src/common.ts +++ b/src/common.ts @@ -18,6 +18,20 @@ import {pkg} from './util'; import extend from 'extend'; import {Readable} from 'stream'; +/** + * TypeScript does not have this type available globally - however `@types/node` includes `undici-types`, which has it: + * - https://www.npmjs.com/package/@types/node/v/18.19.59?activeTab=dependencies + * + * Additionally, this is the TypeScript pattern for type sniffing and `import("undici-types")` is pretty common: + * - https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/globals.d.ts + */ +type _BodyInit = typeof globalThis extends {BodyInit: infer T} + ? T + : import('undici-types').BodyInit; +type _HeadersInit = typeof globalThis extends {HeadersInit: infer T} + ? T + : import('undici-types').HeadersInit; + /** * Support `instanceof` operator for `GaxiosError`s in different versions of this library. * @@ -77,7 +91,7 @@ export class GaxiosError extends Error { constructor( message: string, - public config: GaxiosOptions, + public config: GaxiosOptionsPrepared, public response?: GaxiosResponse, public error?: Error | NodeJS.ErrnoException ) { @@ -94,7 +108,8 @@ export class GaxiosError extends Error { try { this.response.data = translateData( this.config.responseType, - this.response?.data + // workaround for `node-fetch`'s `.data` deprecation... + this.response?.bodyUsed ? this.response?.data : undefined ); } catch { // best effort - don't throw an error within an error @@ -110,7 +125,7 @@ export class GaxiosError extends Error { } if (config.errorRedactor) { - config.errorRedactor({ + config.errorRedactor({ config: this.config, response: this.response, }); @@ -118,40 +133,35 @@ export class GaxiosError extends Error { } } -export interface Headers { - [index: string]: any; -} -export type GaxiosPromise = Promise>; +type GaxiosResponseData = + | ReturnType + | GaxiosOptionsPrepared['data']; -export interface GaxiosXMLHttpRequest { - responseURL: string; -} +export type GaxiosPromise = Promise>; -export interface GaxiosResponse { - config: GaxiosOptions; +export interface GaxiosResponse extends Response { + config: GaxiosOptionsPrepared; data: T; - status: number; - statusText: string; - headers: Headers; - request: GaxiosXMLHttpRequest; } export interface GaxiosMultipartOptions { - headers: Headers; + headers: _HeadersInit; content: string | Readable; } /** * Request options that are used to form the request. */ -export interface GaxiosOptions { +export interface GaxiosOptions extends RequestInit { /** * Optional method to override making the actual HTTP request. Useful * for writing tests. + * + * @deprecated Use {@link GaxiosOptions.fetchImplementation} instead. */ - adapter?: ( - options: GaxiosOptions, - defaultAdapter: (options: GaxiosOptions) => GaxiosPromise + adapter?: ( + options: GaxiosOptionsPrepared, + defaultAdapter: (options: GaxiosOptionsPrepared) => GaxiosPromise ) => GaxiosPromise; url?: string | URL; /** @@ -159,39 +169,80 @@ export interface GaxiosOptions { */ baseUrl?: string; baseURL?: string | URL; - method?: - | 'GET' - | 'HEAD' - | 'POST' - | 'DELETE' - | 'PUT' - | 'CONNECT' - | 'OPTIONS' - | 'TRACE' - | 'PATCH'; - headers?: Headers; - data?: any; - body?: any; /** - * The maximum size of the http response content in bytes allowed. + * The data to send in the {@link RequestInit.body} of the request. Objects will be + * serialized as JSON, except for: + * - `ArrayBuffer` + * - `Blob` + * - `Buffer` (Node.js) + * - `DataView` + * - `File` + * - `FormData` + * - `ReadableStream` + * - `stream.Readable` (Node.js) + * - strings + * - `TypedArray` (e.g. `Uint8Array`, `BigInt64Array`) + * - `URLSearchParams` + * - all other objects where: + * - headers['Content-Type'] === 'application/x-www-form-urlencoded' (serialized as `URLSearchParams`) + * + * In all other cases, if you would like to prevent `application/json` as the + * default `Content-Type` header you must provide a string or readable stream + * rather than an object, e.g.: + * + * ```ts + * {data: JSON.stringify({some: 'data'})} + * {data: fs.readFile('./some-data.jpeg')} + * ``` + */ + data?: + | _BodyInit + | ArrayBuffer + | Blob + | Buffer + | DataView + | File + | FormData + | ReadableStream + | Readable + | string + | ArrayBufferView + | URLSearchParams + | {}; + /** + * The maximum size of the http response `Content-Length` in bytes allowed. */ maxContentLength?: number; /** * The maximum number of redirects to follow. Defaults to 20. + * + * @deprecated non-spec. Should use `20` if enabled per-spec: https://fetch.spec.whatwg.org/#http-redirect-fetch */ maxRedirects?: number; + /** + * @deprecated non-spec. Should use `20` if enabled per-spec: https://fetch.spec.whatwg.org/#http-redirect-fetch + */ follow?: number; /** * A collection of parts to send as a `Content-Type: multipart/related` request. + * + * This is passed to {@link RequestInit.body}. */ multipart?: GaxiosMultipartOptions[]; - params?: any; + params?: GaxiosResponseData; + /** + * @deprecated Use {@link URLSearchParams} instead and pass this directly to {@link GaxiosOptions.data `data`}. + */ paramsSerializer?: (params: {[index: string]: string | number}) => string; timeout?: number; /** * @deprecated ignored */ - onUploadProgress?: (progressEvent: any) => void; + onUploadProgress?: (progressEvent: GaxiosResponseData) => void; + /** + * If the `fetchImplementation` is native `fetch`, the + * stream is a `ReadableStream`, otherwise `readable.Stream` + */ responseType?: | 'arraybuffer' | 'blob' @@ -203,16 +254,28 @@ export interface GaxiosOptions { validateStatus?: (status: number) => boolean; retryConfig?: RetryConfig; retry?: boolean; - // Should be instance of https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal - // interface. Left as 'any' due to incompatibility between spec and abort-controller. - signal?: any; + /** + * Enables aborting via {@link AbortController}. + */ + signal?: AbortSignal; + /** + * @deprecated non-spec. https://github.com/node-fetch/node-fetch/issues/1438 + */ size?: number; /** - * Implementation of `fetch` to use when making the API call. By default, - * will use the browser context if available, and fall back to `node-fetch` - * in node.js otherwise. + * Implementation of `fetch` to use when making the API call. Will use `fetch` by default. + * + * @example + * + * let customFetchCalled = false; + * const myFetch = (...args: Parameters) => { + * customFetchCalled = true; + * return fetch(...args); + * }; + * + * {fetchImplementation: myFetch}; */ - fetchImplementation?: FetchImplementation; + fetchImplementation?: typeof fetch; // Configure client to use mTLS: cert?: string; key?: string; @@ -258,27 +321,14 @@ export interface GaxiosOptions { */ errorRedactor?: typeof defaultErrorRedactor | false; } -/** - * A partial object of `GaxiosResponse` with only redactable keys - * - * @experimental - */ -export type RedactableGaxiosOptions = Pick< - GaxiosOptions, - 'body' | 'data' | 'headers' | 'url' ->; -/** - * A partial object of `GaxiosResponse` with only redactable keys - * - * @experimental - */ -export type RedactableGaxiosResponse = Pick< - GaxiosResponse, - 'config' | 'data' | 'headers' ->; + +export interface GaxiosOptionsPrepared extends GaxiosOptions { + headers: globalThis.Headers; + url: URL; +} /** - * Configuration for the Gaxios `request` method. + * Gaxios retry configuration. */ export interface RetryConfig { /** @@ -359,42 +409,10 @@ export interface RetryConfig { retryDelayMultiplier?: number; } -export type FetchImplementation = ( - input: FetchRequestInfo, - init?: FetchRequestInit -) => Promise; - -export type FetchRequestInfo = any; - -export interface FetchResponse { - readonly status: number; - readonly statusText: string; - readonly url: string; - readonly body: unknown | null; - arrayBuffer(): Promise; - blob(): Promise; - readonly headers: FetchHeaders; - json(): Promise; - text(): Promise; -} - -export interface FetchRequestInit { - method?: string; -} - -export interface FetchHeaders { - append(name: string, value: string): void; - delete(name: string): void; - get(name: string): string | null; - has(name: string): boolean; - set(name: string, value: string): void; - forEach( - callbackfn: (value: string, key: string) => void, - thisArg?: any - ): void; -} - -function translateData(responseType: string | undefined, data: any) { +function translateData( + responseType: string | undefined, + data: GaxiosResponseData +) { switch (responseType) { case 'stream': return data; @@ -417,54 +435,62 @@ function translateData(responseType: string | undefined, data: any) { * * @experimental */ -export function defaultErrorRedactor(data: { - config?: RedactableGaxiosOptions; - response?: RedactableGaxiosResponse; -}) { +export function defaultErrorRedactor< + O extends GaxiosOptionsPrepared, + R extends GaxiosResponse, +>(data: {config?: O; response?: R}) { const REDACT = '< - See `errorRedactor` option in `gaxios` for configuration>.'; function redactHeaders(headers?: Headers) { if (!headers) return; - for (const key of Object.keys(headers)) { + headers.forEach((_, key) => { // any casing of `Authentication` - if (/^authentication$/i.test(key)) { - headers[key] = REDACT; - } - // any casing of `Authorization` - if (/^authorization$/i.test(key)) { - headers[key] = REDACT; - } - // anything containing secret, such as 'client secret' - if (/secret/i.test(key)) { - headers[key] = REDACT; - } - } + if ( + /^authentication$/i.test(key) || + /^authorization$/i.test(key) || + /secret/i.test(key) + ) + headers.set(key, REDACT); + }); } - function redactString(obj: GaxiosOptions, key: keyof GaxiosOptions) { + function redactString(obj: T, key: keyof T) { if ( typeof obj === 'object' && obj !== null && typeof obj[key] === 'string' ) { - const text = obj[key]; + const text = obj[key] as string; if ( /grant_type=/i.test(text) || /assertion=/i.test(text) || /secret/i.test(text) ) { - obj[key] = REDACT; + (obj[key] as {}) = REDACT; } } } - function redactObject(obj: T) { - if (typeof obj === 'object' && obj !== null) { + function redactObject(obj: T | null) { + if (!obj) { + return; + } else if ( + obj instanceof FormData || + obj instanceof URLSearchParams || + // support `node-fetch` FormData/URLSearchParams + ('forEach' in obj && 'set' in obj) + ) { + (obj as FormData | URLSearchParams).forEach((_, key) => { + if (['grant_type', 'assertion'].includes(key) || /secret/.test(key)) { + (obj as FormData | URLSearchParams).set(key, REDACT); + } + }); + } else { if ('grant_type' in obj) { obj['grant_type'] = REDACT; } @@ -488,20 +514,12 @@ export function defaultErrorRedactor(data: { redactString(data.config, 'body'); redactObject(data.config.body); - try { - const url = new URL('', data.config.url); - - if (url.searchParams.has('token')) { - url.searchParams.set('token', REDACT); - } - - if (url.searchParams.has('client_secret')) { - url.searchParams.set('client_secret', REDACT); - } + if (data.config.url.searchParams.has('token')) { + data.config.url.searchParams.set('token', REDACT); + } - data.config.url = url.toString(); - } catch { - // ignore error - no need to parse an invalid URL + if (data.config.url.searchParams.has('client_secret')) { + data.config.url.searchParams.set('client_secret', REDACT); } } @@ -509,8 +527,11 @@ export function defaultErrorRedactor(data: { defaultErrorRedactor({config: data.response.config}); redactHeaders(data.response.headers); - redactString(data.response, 'data'); - redactObject(data.response.data); + // workaround for `node-fetch`'s `.data` deprecation... + if ((data.response as {} as Response).bodyUsed) { + redactString(data.response, 'data'); + redactObject(data.response.data); + } } return data; diff --git a/src/gaxios.ts b/src/gaxios.ts index 987644df..8e2a2d32 100644 --- a/src/gaxios.ts +++ b/src/gaxios.ts @@ -14,55 +14,26 @@ import extend from 'extend'; import {Agent} from 'http'; import {Agent as HTTPSAgent} from 'https'; -import nodeFetch from 'node-fetch'; -import qs from 'querystring'; -import isStream from 'is-stream'; import {URL} from 'url'; +import type nodeFetch from 'node-fetch' with {'resolution-mode': 'import'}; import { - FetchResponse, GaxiosMultipartOptions, GaxiosError, GaxiosOptions, + GaxiosOptionsPrepared, GaxiosPromise, GaxiosResponse, - Headers, defaultErrorRedactor, } from './common'; import {getRetryConfig} from './retry'; -import {PassThrough, Stream, pipeline} from 'stream'; -import {v4} from 'uuid'; +import {Readable} from 'stream'; import {GaxiosInterceptorManager} from './interceptor'; /* eslint-disable @typescript-eslint/no-explicit-any */ -const fetch = hasFetch() ? window.fetch : nodeFetch; - -function hasWindow() { - return typeof window !== 'undefined' && !!window; -} - -function hasFetch() { - return hasWindow() && !!window.fetch; -} - -function hasBuffer() { - return typeof Buffer !== 'undefined'; -} - -function hasHeader(options: GaxiosOptions, header: string) { - return !!getHeader(options, header); -} - -function getHeader(options: GaxiosOptions, header: string): string | undefined { - header = header.toLowerCase(); - for (const key of Object.keys(options?.headers || {})) { - if (header === key.toLowerCase()) { - return options.headers![key]; - } - } - return undefined; -} +const randomUUID = async () => + globalThis.crypto?.randomUUID() || (await import('crypto')).randomUUID(); export class Gaxios { protected agentCache = new Map< @@ -79,7 +50,7 @@ export class Gaxios { * Interceptors */ interceptors: { - request: GaxiosInterceptorManager; + request: GaxiosInterceptorManager; response: GaxiosInterceptorManager; }; @@ -100,18 +71,41 @@ export class Gaxios { * @param opts Set of HTTP options that will be used for this HTTP request. */ async request(opts: GaxiosOptions = {}): GaxiosPromise { - opts = await this.#prepareRequest(opts); - opts = await this.#applyRequestInterceptors(opts); - return this.#applyResponseInterceptors(this._request(opts)); + let prepared = await this.#prepareRequest(opts); + prepared = await this.#applyRequestInterceptors(prepared); + return this.#applyResponseInterceptors(this._request(prepared)); } private async _defaultAdapter( - opts: GaxiosOptions + config: GaxiosOptionsPrepared ): Promise> { - const fetchImpl = opts.fetchImplementation || fetch; - const res = (await fetchImpl(opts.url, opts)) as FetchResponse; - const data = await this.getResponseData(opts, res); - return this.translateResponse(opts, res, data); + const fetchImpl = + config.fetchImplementation || + this.defaults.fetchImplementation || + (await Gaxios.#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 as {})) as Response; + 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}); } /** @@ -119,7 +113,7 @@ export class Gaxios { * @param opts Set of HTTP options that will be used for this HTTP request. */ protected async _request( - opts: GaxiosOptions = {} + opts: GaxiosOptionsPrepared ): GaxiosPromise { try { let translatedResponse: GaxiosResponse; @@ -134,13 +128,12 @@ export class Gaxios { if (!opts.validateStatus!(translatedResponse.status)) { if (opts.responseType === 'stream') { - let response = ''; - await new Promise(resolve => { - (translatedResponse?.data as Stream).on('data', chunk => { - response += chunk; - }); - (translatedResponse?.data as Stream).on('end', resolve); - }); + const response = []; + + for await (const chunk of opts.data as Readable) { + response.push(chunk); + } + translatedResponse.data = response as T; } throw new GaxiosError( @@ -172,21 +165,27 @@ export class Gaxios { } private async getResponseData( - opts: GaxiosOptions, - res: FetchResponse + opts: GaxiosOptionsPrepared, + res: Response ): Promise { + if ( + opts.maxContentLength && + res.headers.has('content-length') && + opts.maxContentLength < + Number.parseInt(res.headers?.get('content-length') || '') + ) { + throw new GaxiosError( + "Response's `Content-Length` is over the limit.", + opts, + Object.assign(res, {config: opts}) as GaxiosResponse + ); + } + switch (opts.responseType) { case 'stream': return res.body; - case 'json': { - let data = await res.text(); - try { - data = JSON.parse(data); - } catch { - // continue - } - return data as {}; - } + case 'json': + return res.json(); case 'arraybuffer': return res.arrayBuffer(); case 'blob': @@ -200,7 +199,7 @@ export class Gaxios { #urlMayUseProxy( url: string | URL, - noProxy: GaxiosOptions['noProxy'] = [] + noProxy: GaxiosOptionsPrepared['noProxy'] = [] ): boolean { const candidate = new URL(url); const noProxyList = [...noProxy]; @@ -248,13 +247,13 @@ export class Gaxios { * Applies the request interceptors. The request interceptors are applied after the * call to prepareRequest is completed. * - * @param {GaxiosOptions} options The current set of options. + * @param {GaxiosOptionsPrepared} options The current set of options. * - * @returns {Promise} Promise that resolves to the set of options or response after interceptors are applied. + * @returns {Promise} Promise that resolves to the set of options or response after interceptors are applied. */ async #applyRequestInterceptors( - options: GaxiosOptions - ): Promise { + options: GaxiosOptionsPrepared + ): Promise { let promiseChain = Promise.resolve(options); for (const interceptor of this.interceptors.request.values()) { @@ -262,7 +261,7 @@ export class Gaxios { promiseChain = promiseChain.then( interceptor.resolved, interceptor.rejected - ) as Promise; + ) as Promise; } } @@ -273,9 +272,9 @@ export class Gaxios { * Applies the response interceptors. The response interceptors are applied after the * call to request is made. * - * @param {GaxiosOptions} options The current set of options. + * @param {GaxiosOptionsPrepared} options The current set of options. * - * @returns {Promise} Promise that resolves to the set of options or response after interceptors are applied. + * @returns {Promise} Promise that resolves to the set of options or response after interceptors are applied. */ async #applyResponseInterceptors( response: GaxiosResponse | Promise @@ -300,7 +299,9 @@ export class Gaxios { * @param options The original options passed from the client. * @returns Prepared options, ready to make a request */ - async #prepareRequest(options: GaxiosOptions): Promise { + async #prepareRequest( + options: GaxiosOptions + ): Promise { const opts = extend(true, {}, this.defaults, options); if (!opts.url) { throw new Error('URL is required.'); @@ -312,14 +313,27 @@ export class Gaxios { opts.url = baseUrl.toString() + opts.url; } - opts.paramsSerializer = opts.paramsSerializer || this.paramsSerializer; - if (opts.params && Object.keys(opts.params).length > 0) { - let additionalQueryParams = opts.paramsSerializer(opts.params); - if (additionalQueryParams.startsWith('?')) { - additionalQueryParams = additionalQueryParams.slice(1); + // 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; } - const prefix = opts.url.toString().includes('?') ? '&' : '?'; - opts.url = opts.url + prefix + additionalQueryParams; } if (typeof options.maxContentLength === 'number') { @@ -330,61 +344,78 @@ export class Gaxios { opts.follow = options.maxRedirects; } - opts.headers = opts.headers || {}; - if (opts.multipart === undefined && opts.data) { - const isFormData = - typeof FormData === 'undefined' - ? false - : opts?.data instanceof FormData; - if (isStream.readable(opts.data)) { - opts.body = opts.data; - } else if (hasBuffer() && Buffer.isBuffer(opts.data)) { - // Do not attempt to JSON.stringify() a Buffer: - opts.body = opts.data; - if (!hasHeader(opts, 'Content-Type')) { - opts.headers['Content-Type'] = 'application/json'; + const preparedHeaders = + opts.headers instanceof Headers + ? opts.headers + : new Headers(opts.headers); + + 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 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 = Readable.from( + this.getMultipartRequest(opts.multipart, boundary) + ) as {} as ReadableStream; + } else if (shouldDirectlyPassData) { + opts.body = opts.data as BodyInit; + + /** + * Used for backwards-compatibility. + * + * @deprecated we shouldn't infer Buffers as JSON + */ + if ('Buffer' in globalThis && Buffer.isBuffer(opts.data)) { + if (!preparedHeaders.has('content-type')) { + preparedHeaders.set('content-type', 'application/json'); } - } else if (typeof opts.data === 'object') { + } + } 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 using querystring: - if (!isFormData) { - if ( - getHeader(opts, 'content-type') === - 'application/x-www-form-urlencoded' - ) { - opts.body = opts.paramsSerializer(opts.data); - } else { - // } else if (!(opts.data instanceof FormData)) { - if (!hasHeader(opts, 'Content-Type')) { - opts.headers['Content-Type'] = 'application/json'; - } - opts.body = JSON.stringify(opts.data); - } - } + // provided as an object, serialize the content + opts.body = opts.paramsSerializer + ? opts.paramsSerializer(opts.data as {}) + : new URLSearchParams(opts.data as {}); } else { - opts.body = opts.data; + if (!preparedHeaders.has('content-type')) { + preparedHeaders.set('content-type', 'application/json'); + } + + opts.body = JSON.stringify(opts.data); } - } else if (opts.multipart && opts.multipart.length > 0) { - // note: once the minimum version reaches Node 16, - // this can be replaced with randomUUID() function from crypto - // and the dependency on UUID removed - const boundary = v4(); - opts.headers['Content-Type'] = `multipart/related; boundary=${boundary}`; - const bodyStream = new PassThrough(); - opts.body = bodyStream; - pipeline( - this.getMultipartRequest(opts.multipart, boundary), - bodyStream, - () => {} - ); + } else if (opts.data) { + opts.body = opts.data as BodyInit; } opts.validateStatus = opts.validateStatus || this.validateStatus; opts.responseType = opts.responseType || 'unknown'; - if (!opts.headers['Accept'] && opts.responseType === 'json') { - opts.headers['Accept'] = 'application/json'; + if (!preparedHeaders.has('accept') && opts.responseType === 'json') { + preparedHeaders.set('accept', 'application/json'); } - opts.method = opts.method || 'GET'; const proxy = opts.proxy || @@ -392,11 +423,10 @@ export class Gaxios { process?.env?.https_proxy || process?.env?.HTTP_PROXY || process?.env?.http_proxy; - const urlMayUseProxy = this.#urlMayUseProxy(opts.url, opts.noProxy); if (opts.agent) { // don't do any of the following options - use the user-provided agent. - } else if (proxy && urlMayUseProxy) { + } else if (proxy && this.#urlMayUseProxy(opts.url, opts.noProxy)) { const HttpsProxyAgent = await Gaxios.#getProxyAgent(); if (this.agentCache.has(proxy)) { @@ -429,7 +459,19 @@ export class Gaxios { opts.errorRedactor = defaultErrorRedactor; } - return opts; + 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 as {duplex: string}).duplex = 'half'; + } + + return Object.assign(opts, { + headers: preparedHeaders, + url: opts.url instanceof URL ? opts.url : new URL(opts.url), + }); } /** @@ -440,46 +482,13 @@ export class Gaxios { return status >= 200 && status < 300; } - /** - * Encode a set of key/value pars into a querystring format (?foo=bar&baz=boo) - * @param params key value pars to encode - */ - private paramsSerializer(params: {[index: string]: string | number}) { - return qs.stringify(params); - } - - private translateResponse( - opts: GaxiosOptions, - res: FetchResponse, - data?: T - ): GaxiosResponse { - // headers need to be converted from a map to an obj - const headers = {} as Headers; - res.headers.forEach((value, key) => { - headers[key] = value; - }); - - return { - config: opts, - data: data as T, - headers, - status: res.status, - statusText: res.statusText, - - // XMLHttpRequestLike - request: { - responseURL: res.url, - }, - }; - } - /** * Attempts to parse a response by looking at the Content-Type header. - * @param {FetchResponse} response the HTTP response. + * @param {Response} response the HTTP response. * @returns {Promise} a promise that resolves to the response data. */ private async getResponseDataFromContentType( - response: FetchResponse + response: Response ): Promise { let contentType = response.headers.get('Content-Type'); if (contentType === null) { @@ -517,8 +526,12 @@ export class Gaxios { ) { const finale = `--${boundary}--`; for (const currentPart of multipartOptions) { + const headers = + currentPart.headers instanceof Headers + ? currentPart.headers + : new Headers(currentPart.headers as HeadersInit); const partContentType = - currentPart.headers['Content-Type'] || 'application/octet-stream'; + 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') { @@ -539,6 +552,14 @@ export class Gaxios { // using `import` to dynamically import the types here static #proxyAgent?: typeof import('https-proxy-agent').HttpsProxyAgent; + /** + * A cache for the lazily-loaded fetch library. + * + * Should use {@link Gaxios[#getFetch]} to retrieve. + */ + // + static #fetch?: typeof nodeFetch | typeof fetch; + /** * Imports, caches, and returns a proxy agent - if not already imported * @@ -549,4 +570,14 @@ export class Gaxios { 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; + } } diff --git a/src/index.ts b/src/index.ts index a18ddef3..c563eac6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ export { GaxiosError, GaxiosPromise, GaxiosResponse, - Headers, + GaxiosOptionsPrepared, RetryConfig, } from './common'; export {Gaxios, GaxiosOptions}; diff --git a/src/interceptor.ts b/src/interceptor.ts index d52aacbb..9ccfad86 100644 --- a/src/interceptor.ts +++ b/src/interceptor.ts @@ -11,12 +11,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GaxiosError, GaxiosOptions, GaxiosResponse} from './common'; +import {GaxiosError, GaxiosOptionsPrepared, GaxiosResponse} from './common'; /** * Interceptors that can be run for requests or responses. These interceptors run asynchronously. */ -export interface GaxiosInterceptor { +export interface GaxiosInterceptor< + T extends GaxiosOptionsPrepared | GaxiosResponse, +> { /** * Function to be run when applying an interceptor. * @@ -37,5 +39,5 @@ export interface GaxiosInterceptor { * Class to manage collections of GaxiosInterceptors for both requests and responses. */ export class GaxiosInterceptorManager< - T extends GaxiosOptions | GaxiosResponse, + T extends GaxiosOptionsPrepared | GaxiosResponse, > extends Set | null> {} diff --git a/src/retry.ts b/src/retry.ts index 4f28f943..6f696676 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -75,7 +75,7 @@ export async function getRetryConfig(err: GaxiosError) { const delay = getNextRetryDelay(config); - // We're going to retry! Incremenent the counter. + // We're going to retry! Increment the counter. err.config.retryConfig!.currentRetryAttempt! += 1; // Create a promise that invokes the retry after the backOffDelay @@ -102,9 +102,11 @@ export async function getRetryConfig(err: GaxiosError) { function shouldRetryRequest(err: GaxiosError) { const config = getConfig(err); - // node-fetch raises an AbortError if signaled: - // https://github.com/bitinn/node-fetch#request-cancellation-with-abortsignal - if (err.name === 'AbortError' || err.error?.name === 'AbortError') { + if ( + err.config.signal?.aborted || + err.name === 'AbortError' || + err.error?.name === 'AbortError' + ) { return false; } @@ -123,8 +125,10 @@ function shouldRetryRequest(err: GaxiosError) { // Only retry with configured HttpMethods. if ( - !err.config.method || - config.httpMethodsToRetry!.indexOf(err.config.method.toUpperCase()) < 0 + !config.httpMethodsToRetry || + !config.httpMethodsToRetry.includes( + err.config.method?.toUpperCase() || 'GET' + ) ) { return false; } diff --git a/system-test/fixtures/sample/webpack.config.js b/system-test/fixtures/sample/webpack.config.js index 1a65d5a4..dd1e3e77 100644 --- a/system-test/fixtures/sample/webpack.config.js +++ b/system-test/fixtures/sample/webpack.config.js @@ -15,6 +15,7 @@ // Use `npm run webpack` to produce Webpack bundle for this library. const path = require('path'); +const webpack = require('webpack'); module.exports = { entry: './src/index.ts', @@ -24,19 +25,22 @@ module.exports = { '../../package.json': path.resolve(__dirname, 'package.json'), }, fallback: { - crypto: false, + buffer: 'browserify', child_process: false, + crypto: false, fs: false, + http: false, http2: false, https: false, - buffer: 'browserify', - process: false, + net: false, os: false, - querystring: false, path: false, + process: false, stream: 'stream-browserify', + 'stream/web': false, url: false, util: false, + worker_threads: false, zlib: false, }, }, @@ -58,5 +62,10 @@ module.exports = { ], }, mode: 'production', - plugins: [], + plugins: [ + // webpack 5 doesn't know what to do with `node:` imports + new webpack.NormalModuleReplacementPlugin(/node:/, resource => { + resource.request = resource.request.replace(/^node:/, ''); + }), + ], }; diff --git a/test/test.getch.ts b/test/test.getch.ts index 26a63bbd..93426f29 100644 --- a/test/test.getch.ts +++ b/test/test.getch.ts @@ -16,7 +16,6 @@ import nock from 'nock'; import sinon from 'sinon'; import stream, {Readable} from 'stream'; import {describe, it, afterEach} from 'mocha'; -import fetch from 'node-fetch'; import {HttpsProxyAgent} from 'https-proxy-agent'; import { Gaxios, @@ -26,12 +25,9 @@ import { GaxiosResponse, GaxiosPromise, } from '../src'; -import {GAXIOS_ERROR_SYMBOL, Headers} from '../src/common'; +import {GAXIOS_ERROR_SYMBOL, GaxiosOptionsPrepared} from '../src/common'; import {pkg} from '../src/util'; -import qs from 'querystring'; import fs from 'fs'; -import {Blob} from 'node-fetch'; -global.FormData = require('form-data'); nock.disableNetConnect(); @@ -107,7 +103,7 @@ describe('🚙 error handling', () => { it('should not throw an error during a translation error', () => { const notJSON = '.'; - const response: GaxiosResponse = { + const response = { config: { responseType: 'json', }, @@ -115,14 +111,17 @@ describe('🚙 error handling', () => { status: 500, statusText: '', headers: {}, - request: { - responseURL: url, - }, - }; - - const error = new GaxiosError('translation test', {}, response); + // workaround for `node-fetch`'s `.data` deprecation... + bodyUsed: true, + } as GaxiosResponse; + + const error = new GaxiosError( + 'translation test', + {} as GaxiosOptionsPrepared, + response + ); - assert(error.response, undefined); + assert(error.response); assert.equal(error.response.data, notJSON); }); @@ -131,7 +130,7 @@ describe('🚙 error handling', () => { const wrongVersion = {[GAXIOS_ERROR_SYMBOL]: '0.0.0'}; const correctVersion = {[GAXIOS_ERROR_SYMBOL]: pkg.version}; - const child = new A('', {}); + const child = new A('', {} as GaxiosOptionsPrepared); assert.equal(wrongVersion instanceof GaxiosError, false); assert.equal(correctVersion instanceof GaxiosError, true); @@ -140,11 +139,18 @@ describe('🚙 error handling', () => { }); describe('🥁 configuration options', () => { - it('should accept URL objects', async () => { + it('should accept `URL` objects', async () => { const scope = nock(url).get('/').reply(204); const res = await request({url: new URL(url)}); scope.done(); - assert.strictEqual(res.config.method, 'GET'); + assert.strictEqual(res.status, 204); + }); + + it('should accept `Request` objects', async () => { + const scope = nock(url).get('/').reply(204); + const res = await request(new Request(url)); + scope.done(); + assert.strictEqual(res.status, 204); }); it('should use options passed into the constructor', async () => { @@ -160,8 +166,8 @@ describe('🥁 configuration options', () => { const inst = new Gaxios({headers: {apple: 'juice'}}); const res = await inst.request({url, headers: {figgy: 'pudding'}}); scope.done(); - assert.strictEqual(res.config.headers!.apple, 'juice'); - assert.strictEqual(res.config.headers!.figgy, 'pudding'); + assert.strictEqual(res.config.headers.get('apple'), 'juice'); + assert.strictEqual(res.config.headers.get('figgy'), 'pudding'); }); it('should allow setting a base url in the options', async () => { @@ -181,9 +187,14 @@ describe('🥁 configuration options', () => { it('should allow setting maxContentLength', async () => { const body = {hello: '🌎'}; - const scope = nock(url).get('/').reply(200, body); + const scope = nock(url) + .get('/') + .reply(200, body, {'content-length': body.toString().length.toString()}); const maxContentLength = 1; - await assert.rejects(request({url, maxContentLength}), /over limit/); + await assert.rejects(request({url, maxContentLength}), (err: Error) => { + return err instanceof GaxiosError && /limit/.test(err.message); + }); + scope.done(); }); @@ -196,27 +207,17 @@ describe('🥁 configuration options', () => { const res = await request({url}); scopes.forEach(x => x.done()); assert.deepStrictEqual(res.data, body); - assert.strictEqual(res.request.responseURL, `${url}/foo`); - }); - - it('should support disabling redirects', async () => { - const scope = nock(url).get('/').reply(302, undefined, {location: '/foo'}); - const maxRedirects = 0; - await assert.rejects(request({url, maxRedirects}), /maximum redirect/); - scope.done(); + assert.strictEqual(res.url, `${url}/foo`); }); it('should allow overriding the adapter', async () => { - const response: GaxiosResponse = { + const response = { data: {hello: '🌎'}, config: {}, status: 200, statusText: 'OK', - headers: {}, - request: { - responseURL: url, - }, - }; + headers: new Headers(), + } as GaxiosResponse; const adapter = () => Promise.resolve(response); const res = await request({url, adapter}); assert.strictEqual(response, res); @@ -256,7 +257,7 @@ describe('🥁 configuration options', () => { const scope = nock(url).get(path).reply(200, {}); const res = await request(opts); assert.strictEqual(res.status, 200); - assert.strictEqual(res.config.url, url + path); + assert.strictEqual(res.config.url?.toString(), url + path); scope.done(); }); @@ -266,7 +267,7 @@ describe('🥁 configuration options', () => { const scope = nock(url).get(path).reply(200, {}); const res = await request(opts); assert.strictEqual(res.status, 200); - assert.strictEqual(res.config.url, url + path); + assert.strictEqual(res.config.url?.toString(), url + path); scope.done(); }); @@ -287,7 +288,10 @@ describe('🥁 configuration options', () => { const scope = nock(url).get(path).reply(200, {}); const res = await request(opts); assert.strictEqual(res.status, 200); - assert.strictEqual(res.config.url, url + qs); + assert.strictEqual( + res.config.url?.toString(), + new URL(url + qs).toString() + ); scope.done(); }); @@ -300,7 +304,7 @@ describe('🥁 configuration options', () => { const scope = nock(url).get(path).reply(200, {}); const res = await request(opts); assert.strictEqual(res.status, 200); - assert.strictEqual(res.config.url, url + path); + assert.strictEqual(res.config.url?.toString(), url + path); scope.done(); }); @@ -318,7 +322,7 @@ describe('🥁 configuration options', () => { const scope = nock(url).get(`/${qs}`).reply(200, {}); const res = await request(opts); assert.strictEqual(res.status, 200); - assert.strictEqual(res.config.url, url + qs); + assert.strictEqual(res.config.url.toString(), new URL(url + qs).toString()); scope.done(); }); @@ -606,8 +610,30 @@ describe('🥁 configuration options', () => { it('should not stringify the data if it is appended by a form', async () => { const formData = new FormData(); formData.append('test', '123'); - // I don't think matching formdata is supported in nock, so skipping: https://github.com/nock/nock/issues/887 - const scope = nock(url).post('/').reply(200); + + const scope = nock(url) + .post('/', body => { + /** + * Sample from native `node-fetch` + * body: '------3785545705014550845559551617\r\n' + + * 'Content-Disposition: form-data; name="test"\r\n' + + * '\r\n' + + * '123\r\n' + + * '------3785545705014550845559551617--', + */ + + /** + * Sample from native `fetch` + * body: '------formdata-undici-0.39470493152687736\r\n' + + * 'Content-Disposition: form-data; name="test"\r\n' + + * '\r\n' + + * '123\r\n' + + * '------formdata-undici-0.39470493152687736--', + */ + + return body.match('Content-Disposition: form-data;'); + }) + .reply(200); const res = await request({ url, method: 'POST', @@ -615,15 +641,28 @@ describe('🥁 configuration options', () => { }); scope.done(); assert.deepStrictEqual(res.config.data, formData); + assert.ok(res.config.body instanceof FormData); assert.ok(res.config.data instanceof FormData); - assert.deepEqual(res.config.body, undefined); }); - it('should allow explicitly setting the fetch implementation to node-fetch', async () => { - const scope = nock(url).get('/').reply(200); - const res = await request({url, fetchImplementation: fetch}); + it('should allow explicitly setting the fetch implementation', async () => { + let customFetchCalled = false; + const myFetch = (...args: Parameters) => { + customFetchCalled = true; + return fetch(...args); + }; + + const scope = nock(url).post('/').reply(204); + const res = await request({ + url, + method: 'POST', + fetchImplementation: myFetch, + // This `data` ensures the 'duplex' option has been set + data: {sample: 'data'}, + }); + assert(customFetchCalled); + assert.equal(res.status, 204); scope.done(); - assert.deepStrictEqual(res.status, 200); }); it('should be able to disable the `errorRedactor`', async () => { @@ -666,10 +705,10 @@ describe('🎏 data handling', () => { it('should accept a string in the request data', async () => { const body = {hello: '🌎'}; - const encoded = qs.stringify(body); + const encoded = new URLSearchParams(body); const scope = nock(url) .matchHeader('content-type', 'application/x-www-form-urlencoded') - .post('/', encoded) + .post('/', encoded.toString()) .reply(200, {}); const res = await request({ url, @@ -718,7 +757,7 @@ describe('🎏 data handling', () => { const body = {hello: '🌎'}; const scope = nock(url) .matchHeader('Content-Type', 'application/x-www-form-urlencoded') - .post('/', qs.stringify(body)) + .post('/', new URLSearchParams(body).toString()) .reply(200, {}); const res = await request({ url, @@ -740,6 +779,18 @@ describe('🎏 data handling', () => { assert(res.data instanceof stream.Readable); }); + it('should return a `ReadableStream` when `fetch` has been provided ', async () => { + const body = {hello: '🌎'}; + const scope = nock(url).get('/').reply(200, body); + const res = await request({ + url, + responseType: 'stream', + fetchImplementation: fetch, + }); + scope.done(); + assert(res.data instanceof ReadableStream); + }); + it('should return an ArrayBuffer if asked nicely', async () => { const body = {hello: '🌎'}; const scope = nock(url).get('/').reply(200, body); @@ -777,7 +828,10 @@ describe('🎏 data handling', () => { const res = await request({url}); scope.done(); assert.ok(res.data); - assert.strictEqual(res.statusText, 'OK'); + // node-fetch and native fetch specs differ... + // https://github.com/node-fetch/node-fetch/issues/1066 + assert.strictEqual(typeof res.statusText, 'string'); + // assert.strictEqual(res.statusText, 'OK'); }); it('should return JSON when response Content-Type=application/json', async () => { @@ -907,7 +961,7 @@ describe('🎏 data handling', () => { customURL.searchParams.append('client_secret', 'data'); customURL.searchParams.append('random', 'non-sensitive'); - const config: GaxiosOptions = { + const config = { headers: { Authentication: 'My Auth', /** @@ -924,7 +978,7 @@ describe('🎏 data handling', () => { client_secret: 'data', }, body: 'grant_type=somesensitivedata&assertion=somesensitivedata&client_secret=data', - }; + } as const; // simulate JSON response const responseHeaders = { @@ -958,12 +1012,16 @@ describe('🎏 data handling', () => { assert.notStrictEqual(e.config, config); // config redactions - headers - assert(e.config.headers); - assert.deepStrictEqual(e.config.headers, { + const expectedRequestHeaders = new Headers({ ...config.headers, // non-redactables should be present Authentication: REDACT, AUTHORIZATION: REDACT, }); + const actualHeaders = e.config.headers; + + expectedRequestHeaders.forEach((value, key) => { + assert.equal(actualHeaders.get(key), value); + }); // config redactions - data assert.deepStrictEqual(e.config.data, { @@ -973,8 +1031,19 @@ describe('🎏 data handling', () => { client_secret: REDACT, }); - // config redactions - body - assert.deepStrictEqual(e.config.body, REDACT); + assert.deepStrictEqual( + Object.fromEntries(e.config.body as URLSearchParams), + { + ...config.data, // non-redactables should be present + grant_type: REDACT, + assertion: REDACT, + client_secret: REDACT, + } + ); + + expectedRequestHeaders.forEach((value, key) => { + assert.equal(actualHeaders.get(key), value); + }); // config redactions - url assert(e.config.url); @@ -988,16 +1057,17 @@ describe('🎏 data handling', () => { assert(e.response); assert.deepStrictEqual(e.response.config, e.config); - const expectedHeaders: Headers = { + const expectedResponseHeaders = new Headers({ ...responseHeaders, // non-redactables should be present - authentication: REDACT, - authorization: REDACT, - }; + }); + + expectedResponseHeaders.set('authentication', REDACT); + expectedResponseHeaders.set('authorization', REDACT); - delete expectedHeaders['AUTHORIZATION']; - delete expectedHeaders['Authentication']; + expectedResponseHeaders.forEach((value, key) => { + assert.equal(e.response?.headers.get(key), value); + }); - assert.deepStrictEqual(e.response.headers, expectedHeaders); assert.deepStrictEqual(e.response.data, { ...response, // non-redactables should be present assertion: REDACT, @@ -1065,7 +1135,7 @@ describe('🍂 defaults & instances', () => { } // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async _request( - opts: GaxiosOptions = {} + opts: GaxiosOptionsPrepared ): GaxiosPromise { assert(opts.agent); return super._request(opts); @@ -1081,8 +1151,8 @@ describe('🍂 defaults & instances', () => { }); const res = await inst.request({url, headers: {figgy: 'pudding'}}); scope.done(); - assert.strictEqual(res.config.headers!.apple, 'juice'); - assert.strictEqual(res.config.headers!.figgy, 'pudding'); + assert.strictEqual(res.config.headers.get('apple'), 'juice'); + assert.strictEqual(res.config.headers.get('figgy'), 'pudding'); const agentCache = inst.getAgentCache(); assert(agentCache.get(key)); }); @@ -1113,7 +1183,7 @@ describe('interceptors', () => { const instance = new Gaxios(); instance.interceptors.request.add({ resolved: config => { - config.headers = {hello: 'world'}; + config.headers.set('hello', 'world'); return Promise.resolve(config); }, }); @@ -1130,7 +1200,7 @@ describe('interceptors', () => { validateStatus: () => { return true; }, - }) as unknown as Promise + }) as unknown as Promise ); const instance = new Gaxios(); const interceptor = {resolved: spyFunc}; @@ -1152,22 +1222,22 @@ describe('interceptors', () => { const instance = new Gaxios(); instance.interceptors.request.add({ resolved: config => { - config.headers!['foo'] = 'bar'; + config.headers.set('foo', 'bar'); return Promise.resolve(config); }, }); instance.interceptors.request.add({ resolved: config => { - assert.strictEqual(config.headers!['foo'], 'bar'); - config.headers!['bar'] = 'baz'; + assert.strictEqual(config.headers.get('foo'), 'bar'); + config.headers.set('bar', 'baz'); return Promise.resolve(config); }, }); instance.interceptors.request.add({ resolved: config => { - assert.strictEqual(config.headers!['foo'], 'bar'); - assert.strictEqual(config.headers!['bar'], 'baz'); - config.headers!['baz'] = 'buzz'; + assert.strictEqual(config.headers.get('foo'), 'bar'); + assert.strictEqual(config.headers.get('bar'), 'baz'); + config.headers.set('baz', 'buzz'); return Promise.resolve(config); }, }); @@ -1184,7 +1254,7 @@ describe('interceptors', () => { validateStatus: () => { return true; }, - }) as unknown as Promise + }) as unknown as Promise ); const instance = new Gaxios(); instance.interceptors.request.add({ @@ -1212,7 +1282,7 @@ describe('interceptors', () => { }); instance.interceptors.request.add({ resolved: config => { - config.headers = {hello: 'world'}; + config.headers.set('hello', 'world'); return Promise.resolve(config); }, rejected: err => { @@ -1230,13 +1300,13 @@ describe('interceptors', () => { const instance = new Gaxios(); instance.interceptors.response.add({ resolved(response) { - response.headers['hello'] = 'world'; + response.headers.set('hello', 'world'); return Promise.resolve(response); }, }); const resp = await instance.request({url}); scope.done(); - assert.strictEqual(resp.headers['hello'], 'world'); + assert.strictEqual(resp.headers.get('hello'), 'world'); }); it('should not invoke a response interceptor after it is removed', async () => { @@ -1265,30 +1335,30 @@ describe('interceptors', () => { const instance = new Gaxios(); instance.interceptors.response.add({ resolved: response => { - response.headers!['foo'] = 'bar'; + response.headers.set('foo', 'bar'); return Promise.resolve(response); }, }); instance.interceptors.response.add({ resolved: response => { - assert.strictEqual(response.headers!['foo'], 'bar'); - response.headers!['bar'] = 'baz'; + assert.strictEqual(response.headers.get('foo'), 'bar'); + response.headers.set('bar', 'baz'); return Promise.resolve(response); }, }); instance.interceptors.response.add({ resolved: response => { - assert.strictEqual(response.headers!['foo'], 'bar'); - assert.strictEqual(response.headers!['bar'], 'baz'); - response.headers!['baz'] = 'buzz'; + assert.strictEqual(response.headers.get('foo'), 'bar'); + assert.strictEqual(response.headers.get('bar'), 'baz'); + response.headers.set('baz', 'buzz'); return Promise.resolve(response); }, }); const resp = await instance.request({url, headers: {}}); scope.done(); - assert.strictEqual(resp.headers['foo'], 'bar'); - assert.strictEqual(resp.headers['bar'], 'baz'); - assert.strictEqual(resp.headers['baz'], 'buzz'); + assert.strictEqual(resp.headers.get('foo'), 'bar'); + assert.strictEqual(resp.headers.get('bar'), 'baz'); + assert.strictEqual(resp.headers.get('baz'), 'buzz'); }); it('should not invoke a any response interceptors after they are removed', async () => { diff --git a/test/test.retry.ts b/test/test.retry.ts index 7412da5f..00ecbe16 100644 --- a/test/test.retry.ts +++ b/test/test.retry.ts @@ -11,7 +11,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {AbortController} from 'abort-controller'; import assert from 'assert'; import nock from 'nock'; import {describe, it, afterEach} from 'mocha'; @@ -249,7 +248,7 @@ describe('🛸 retry & exponential backoff', () => { it('should retry on ENOTFOUND', async () => { const body = {spicy: '🌮'}; const scopes = [ - nock(url).get('/').replyWithError({code: 'ENOTFOUND'}), + nock(url).get('/').reply(500, {code: 'ENOTFOUND'}), nock(url).get('/').reply(200, body), ]; const res = await request({url, retry: true}); @@ -260,7 +259,7 @@ describe('🛸 retry & exponential backoff', () => { it('should retry on ETIMEDOUT', async () => { const body = {sizzling: '🥓'}; const scopes = [ - nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}), + nock(url).get('/').reply(500, {code: 'ETIMEDOUT'}), nock(url).get('/').reply(200, body), ]; const res = await request({url, retry: true}); @@ -269,13 +268,14 @@ describe('🛸 retry & exponential backoff', () => { }); it('should allow configuring noResponseRetries', async () => { - const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); + // `nock` is not listening, therefore it should fail const config = {url, retryConfig: {noResponseRetries: 0}}; - await assert.rejects(request(config), (e: Error) => { - const cfg = getConfig(e); - return cfg!.currentRetryAttempt === 0; + await assert.rejects(request(config), (e: GaxiosError) => { + return ( + e.code === 'ENETUNREACH' && + e.config.retryConfig?.currentRetryAttempt === 0 + ); }); - scope.done(); }); it('should delay the initial retry by 100ms by default', async () => { diff --git a/tsconfig.json b/tsconfig.json index dac05e8b..e10edbe0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,12 @@ { "extends": "./node_modules/gts/tsconfig-google.json", "compilerOptions": { - "lib": ["es2015", "dom"], + "lib": ["es2020", "dom"], "rootDir": ".", "outDir": "build", - "esModuleInterop": true + "esModuleInterop": true, + "module": "Node16", + "moduleResolution": "Node16", }, "include": [ "src/*.ts", diff --git a/webpack-tests.config.js b/webpack-tests.config.js index 2590ab03..e29599a2 100644 --- a/webpack-tests.config.js +++ b/webpack-tests.config.js @@ -32,7 +32,6 @@ module.exports = { buffer: 'browserify', process: false, os: false, - querystring: false, path: false, stream: 'stream-browserify', url: false, diff --git a/webpack.config.js b/webpack.config.js index bbb1b492..0621b6dd 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -32,7 +32,6 @@ module.exports = { buffer: 'browserify', process: false, os: false, - querystring: false, path: false, stream: 'stream-browserify', url: false,