Skip to content

Commit

Permalink
Merge branch 'master' into pimm/no-to-plain-object
Browse files Browse the repository at this point in the history
  • Loading branch information
janpaepke authored Sep 10, 2024
2 parents 91bc766 + 476dc50 commit d3c0f70
Show file tree
Hide file tree
Showing 60 changed files with 3,378 additions and 3,413 deletions.
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",
"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'>;
};

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

0 comments on commit d3c0f70

Please sign in to comment.