Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace axios with (node-)fetch #358

Merged
merged 23 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
89e60e7
Replace axios with node-fetch
dsempel Jul 9, 2024
d77565b
Fix some tests
dsempel Jul 9, 2024
c7787e1
Rewrite tests to use Nock instead of the Axios adapter.
Pimm Jul 9, 2024
2b85fa5
Add Nock restoration.
Pimm Jul 10, 2024
9e97c1b
Fix retrying mechanism for fetch
dsempel Aug 1, 2024
f07a902
Fix some more tests and remove axios references
dsempel Aug 1, 2024
454d660
Rewrite networking tests to use Nock instead of the Axios adapter.
Pimm Aug 1, 2024
66d08fa
Rewrite inspect tests to use Nock instead of the Axios adapter.
Pimm Aug 18, 2024
c43aeaa
Change behaviour of NetworkClient for DELETE requests without body.
Pimm Aug 18, 2024
ebc42b2
Fix method (unit) tests.
Pimm Aug 18, 2024
cae8fef
Move Axios dependency to dev dependencies, as it is merely used in th…
Pimm Aug 18, 2024
e35da05
Lower Node.js version requirement to 8.×.×.
Pimm Aug 18, 2024
c9478c9
Refactor processFetchResponse slightly.
Pimm Sep 6, 2024
733259f
Use fetch in getAccessTokenClientProvider instead of Axios.
Pimm Sep 6, 2024
d468772
Remove wireMockClient.
Pimm Sep 6, 2024
c23df8a
revert pathnames to have no starting slash
janpaepke Sep 6, 2024
43781ca
Fix onboarding test.
Pimm Sep 6, 2024
64c6157
use nullish check instead, as per https://github.com/mollie/mollie-ap…
janpaepke Sep 10, 2024
cae30e4
improve docs, as per https://github.com/mollie/mollie-api-node/pull/3…
janpaepke Sep 10, 2024
fe066d7
centralised error management, as per https://github.com/mollie/mollie…
janpaepke Sep 10, 2024
cf9ed41
improve request docs, as per https://github.com/mollie/mollie-api-nod…
janpaepke Sep 10, 2024
a4568aa
small fix to secretly support node 8
janpaepke Sep 10, 2024
c3b6ce8
Update polyfill comments to reflect secret support.
Pimm Sep 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"jsnext:main": "dist/mollie.esm.js",
"types": "dist/types/src/types.d.ts",
"engines": {
"node": ">=14"
"node": ">=8"
},
"scripts": {
"prepublish": "yarn build",
Expand All @@ -41,7 +41,7 @@
"lint": "yarn lint:eslint:fix && yarn lint:prettier"
},
"dependencies": {
"axios": "^1.6.2",
"node-fetch": "^2.7.0",
"ruply": "^1.0.1"
},
"devDependencies": {
Expand All @@ -51,7 +51,7 @@
"@mollie/eslint-config-typescript": "^1.6.5",
"@types/jest": "^29.5.11",
"@types/node": "^18.14.6",
"axios-mock-adapter": "1.19.0",
"@types/node-fetch": "^2.6.11",
janpaepke marked this conversation as resolved.
Show resolved Hide resolved
"babel-jest": "^29.5.0",
"commitizen": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
Expand Down
6 changes: 2 additions & 4 deletions src/Options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { type AxiosRequestConfig } from 'axios';

import type Xor from './types/Xor';

type Options = Xor<
Expand Down Expand Up @@ -29,7 +27,7 @@ type Options = Xor<
* The URL of the root of the Mollie API. Default: `'https://api.mollie.com:443/v2/'`.
*/
apiEndpoint?: string;
} & Pick<AxiosRequestConfig, 'adapter' | 'proxy' | 'socketPath' | 'timeout'>;
};

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should document that this is a breaking change.

const falsyDescriptions = new Map<any, string>([
[undefined, 'undefined'],
Expand All @@ -46,7 +44,7 @@ function describeFalsyOption(options: Options, key: keyof Options) {
return null;
}
return `Parameter "${key}" is ${falsyDescriptions.get(options[key]) ?? options[key]}.`;
};
}

/**
* Throws a `TypeError` if the passed options object does not contain an `apiKey` or an `accessToken`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type TransformingNetworkClient from '../../../communication/TransformingNetworkClient';
import {type IssuerData} from '../../../data/issuer/IssuerModel';
import { type IssuerData } from '../../../data/issuer/IssuerModel';
import type IssuerModel from '../../../data/issuer/IssuerModel';
import ApiError from '../../../errors/ApiError';
import renege from '../../../plumbing/renege';
Expand Down
144 changes: 83 additions & 61 deletions src/communication/NetworkClient.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import https from 'https';
import fetch, { type RequestInit } from 'node-fetch';
import { type SecureContextOptions } from 'tls';

import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type RawAxiosRequestHeaders } from 'axios';

import { run } from 'ruply';
import type Page from '../data/page/Page';
import ApiError from '../errors/ApiError';
import type Options from '../Options';
import fling from '../plumbing/fling';
import DemandingIterator from '../plumbing/iteration/DemandingIterator';
import HelpfulIterator from '../plumbing/iteration/HelpfulIterator';
import Throttler from '../plumbing/Throttler';
Expand All @@ -15,8 +15,9 @@ import { type IdempotencyParameter } from '../types/parameters';
import breakUrl from './breakUrl';
import buildUrl, { type SearchParameters } from './buildUrl';
import dromedaryCase from './dromedaryCase';
import makeRetrying, { idempotencyHeaderName } from './makeRetrying';
import fling from '../plumbing/fling';
import { idempotencyHeaderName, ResponseWithIdempotencyKey, retryingFetch } from './makeRetrying';
// The following line is only necessary for Node.js < 10.0.0, which we only secretly support. Should we ever drop that support completely, we can remove this import.
import { URL } from 'url';

/**
* Like `[].map` but with support for non-array inputs, in which case this function behaves as if an array was passed
Expand Down Expand Up @@ -69,21 +70,45 @@ const throwApiError = run(
},
findProperty =>
function throwApiError(cause: unknown) {
if (findProperty(cause, 'response') && cause.response != undefined) {
throw ApiError.createFromResponse(cause.response as AxiosResponse<any>);
}
throw new ApiError(findProperty(cause, 'message') ? String(cause.message) : 'An unknown error has occurred');
},
);

/**
* Checks whether an API error needs to be thrown based on the passed result.
*/
async function processFetchResponse(response: ResponseWithIdempotencyKey) {
// Request was successful, but no content was returned.
if (response.status == 204) return true;
// Request was successful and content was returned.
const body = await response.json();
if (Math.floor(response.status / 100) == 2) {
return body;
}
// Request was not successful, but the response body contains an error message.
if (null != body) {
throw ApiError.createFromResponse(body, response.idempotencyKey);
}
// Request was not successful.
throw new ApiError('An unknown error has occurred');
}

interface Data {}
interface Context {}

/**
* This class is essentially a wrapper around axios. It simplifies communication with the Mollie API over the network.
* This class is essentially a wrapper around fetch. It simplifies communication with the Mollie API over the network.
*/
export default class NetworkClient {
protected readonly axiosInstance: AxiosInstance;
/**
* Triggers a request to the Mollie API.
*
* In contrast to the underlying `fetch` function, this function will:
* - retry the request in some scenarios (see `retryingFetch`)
* - throw an `ApiError` if the response from the Mollie API indicates an error
* - appropriately process the response body before returning it (i.e. parsing it as JSON or throwing an ApiError if the response status indicates an error)
*/
protected readonly request: (pathname: string, options?: RequestInit) => Promise<any>;
constructor({
apiKey,
accessToken,
Expand All @@ -92,71 +117,69 @@ export default class NetworkClient {
caCertificates,
libraryVersion,
nodeVersion,
...axiosOptions
}: Options & { caCertificates?: SecureContextOptions['ca']; libraryVersion: string; nodeVersion: string }) {
axiosOptions.headers = { ...axiosOptions.headers };
// Compose the headers set in the sent requests.
axiosOptions.headers['User-Agent'] = composeUserAgent(nodeVersion, libraryVersion, versionStrings);
const headers: Record<string, string> = {};
headers['User-Agent'] = composeUserAgent(nodeVersion, libraryVersion, versionStrings);
if (apiKey != undefined) {
axiosOptions.headers['Authorization'] = `Bearer ${apiKey}`;
headers['Authorization'] = `Bearer ${apiKey}`;
} /* if (accessToken != undefined) */ else {
axiosOptions.headers['Authorization'] = `Bearer ${accessToken}`;
axiosOptions.headers['User-Agent'] += ' OAuth/2.0';
headers['Authorization'] = `Bearer ${accessToken}`;
headers['User-Agent'] += ' OAuth/2.0';
}
axiosOptions.headers['Accept'] = 'application/hal+json';
axiosOptions.headers['Accept-Encoding'] = 'gzip';
axiosOptions.headers['Content-Type'] = 'application/json';
// Create the Axios instance.
this.axiosInstance = axios.create({
...axiosOptions,
baseURL: apiEndpoint,
httpsAgent: new https.Agent({
ca: caCertificates,
}),
});
// Make the Axios instance request multiple times is some scenarios.
makeRetrying(this.axiosInstance);
headers['Accept'] = 'application/hal+json';
headers['Accept-Encoding'] = 'gzip';
headers['Content-Type'] = 'application/json';

// Create the https agent.
const agent = new https.Agent({ ca: caCertificates });

// Create retrying fetch function.
const fetchWithRetries = retryingFetch(fetch);

// Create the request function.
this.request = (pathname, options) => {
const url = new URL(pathname, apiEndpoint);
return fetchWithRetries(url, { agent, ...options, headers: { ...headers, ...options?.headers } })
.catch(throwApiError)
.then(processFetchResponse);
};
}

async post<R>(pathname: string, data: Data & IdempotencyParameter, query?: SearchParameters): Promise<R | true> {
// Take the idempotency key from the data, if any. It would be cleaner from a design perspective to have the
// idempotency key in a separate argument instead of cramming it into the data like this. However, having a
// separate argument would require every endpoint to split the input into those two arguments and thus cause a lot
// of boiler-plate code.
let config: AxiosRequestConfig | undefined = undefined;
if (data.idempotencyKey != undefined) {
const { idempotencyKey, ...rest } = data;
config = { headers: { [idempotencyHeaderName]: idempotencyKey } };
data = rest;
}
const response = await this.axiosInstance.post(buildUrl(pathname, query), data, config).catch(throwApiError);
if (response.status == 204) {
return true;
}
return response.data;
const { idempotencyKey, ...body } = data;
const config: RequestInit = {
method: 'POST',
headers: idempotencyKey ? { [idempotencyHeaderName]: idempotencyKey } : undefined,
body: JSON.stringify(body),
};
return this.request(buildUrl(pathname, query), config);
}

async get<R>(pathname: string, query?: SearchParameters): Promise<R> {
const response = await this.axiosInstance.get(buildUrl(pathname, query)).catch(throwApiError);
return response.data;
return this.request(buildUrl(pathname, query));
}

async list<R>(pathname: string, binderName: string, query?: SearchParameters): Promise<R[]> {
const response = await this.axiosInstance.get(buildUrl(pathname, query)).catch(throwApiError);
const data = await this.request(buildUrl(pathname, query));
try {
/* eslint-disable-next-line no-var */
var { _embedded: embedded } = response.data;
var { _embedded: embedded } = data;
} catch (error) {
throw new ApiError('Received unexpected response from the server');
}
return embedded[binderName] as R[];
}

async page<R>(pathname: string, binderName: string, query?: SearchParameters): Promise<R[] & Pick<Page<R>, 'links' | 'count'>> {
const response = await this.axiosInstance.get(buildUrl(pathname, query)).catch(throwApiError);
const data = await this.request(buildUrl(pathname, query));
try {
/* eslint-disable-next-line no-var */
var { _embedded: embedded, _links: links, count } = response.data;
var { _embedded: embedded, _links: links, count } = data;
} catch (error) {
throw new ApiError('Received unexpected response from the server');
}
Expand Down Expand Up @@ -196,16 +219,16 @@ export default class NetworkClient {
// and valuesPerMinute is set to 100, all 250 values received values will be yielded before the (two-minute)
// break.
const throttler = new Throttler(valuesPerMinute);
const { axiosInstance } = this;
const { request } = this;
return new HelpfulIterator<R>(
(async function* iterate<R>() {
let url = buildUrl(pathname, { ...query, limit: popLimit() });
while (true) {
// Request and parse the page from the Mollie API.
const response = await axiosInstance.get(url).catch(throwApiError);
const data = await request(url);
try {
/* eslint-disable-next-line no-var */
var { _embedded: embedded, _links: links } = response.data;
var { _embedded: embedded, _links: links } = data;
} catch (error) {
throw new ApiError('Received unexpected response from the server');
}
Expand All @@ -230,22 +253,21 @@ export default class NetworkClient {
}

async patch<R>(pathname: string, data: Data): Promise<R> {
const response = await this.axiosInstance.patch(pathname, data).catch(throwApiError);
return response.data;
const config: RequestInit = {
method: 'PATCH',
body: JSON.stringify(data),
};
return this.request(buildUrl(pathname), config);
}

async delete<R>(pathname: string, context?: Context & IdempotencyParameter): Promise<R | true> {
// Take the idempotency key from the context, if any.
let headers: RawAxiosRequestHeaders | undefined = undefined;
if (context?.idempotencyKey != undefined) {
const { idempotencyKey, ...rest } = context;
headers = { [idempotencyHeaderName]: idempotencyKey };
context = rest;
}
const response = await this.axiosInstance.delete(pathname, { data: context, headers }).catch(throwApiError);
if (response.status == 204) {
return true;
}
return response.data as R;
const { idempotencyKey, ...body } = context ?? {};
const config: RequestInit = {
method: 'DELETE',
headers: idempotencyKey ? { [idempotencyHeaderName]: idempotencyKey } : undefined,
body: JSON.stringify(body),
};
return this.request(buildUrl(pathname), config);
}
}
2 changes: 1 addition & 1 deletion src/communication/breakUrl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// If support for Node.js < 10.0.0 is ever dropped, this import can be removed.
// The following line is only necessary for Node.js < 10.0.0, which we only secretly support. Should we ever drop that support completely, we can remove this import.
import { URL } from 'url';

import buildFromEntries from '../plumbing/buildFromEntries';
Expand Down
2 changes: 1 addition & 1 deletion src/communication/buildUrl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// If support for Node.js < 10.0.0 is ever dropped, this import can be removed.
// The following line is only necessary for Node.js < 10.0.0, which we only secretly support. Should we ever drop that support completely, we can remove this import.
import { URLSearchParams } from 'url';

import { apply, runIf } from 'ruply';
Expand Down
Loading
Loading