'use strict';
-const axios = require('axios');
const { mf2 } = require('microformats-parser');
const { parse: parseLinkHeader } = require('@squeep/web-linking');
const { Iconv } = require('iconv');
const { version: packageVersion, name: packageName } = require('../package.json');
-const { performance } = require('perf_hooks');
const { randomBytes, createHash } = require('crypto');
const { promisify } = require('util');
const randomBytesAsync = promisify(randomBytes);
/**
* @param {Console} logger
* @param {Object} options
+ * @param {Number=} options.timeout
* @param {Object=} options.userAgent
* @param {String=} options.userAgent.product
* @param {String=} options.userAgent.version
constructor(logger, options = {}) {
this.logger = logger;
this.options = options;
- this.axios = axios.create({
- headers: {
- [Enum.Header.UserAgent]: Communication._userAgentString(options.userAgent),
- [Enum.Header.Accept]: 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1',
- },
- });
- this.axios.interceptors.request.use((request) => {
- request.startTimestampMs = performance.now();
- return request;
- });
- this.axios.interceptors.response.use((response) => {
- response.elapsedTimeMs = performance.now() - response.config.startTimestampMs;
- return response;
- });
+
+ this._defaultAccept = options?.defaultAccept || 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1';
+ this._jsonAccept = options?.jsonAccept || [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', ');
+
+ this.Got = undefined;
+ this.got = this._init; // Do the dynamic import on first attempt to use client.
+ }
+
+
+ /**
+ * Do a little dance to support this ESM client.
+ */
+ async _init(...args) {
+ if (!this.Got) {
+ // For some reason eslint is confused about import being supported here.
+ // eslint-disable-next-line
+ this.Got = await import('got');
+ this.got = this.Got.got.extend({
+ headers: {
+ [Enum.Header.UserAgent]: Communication._userAgentString(this.options.userAgent),
+ [Enum.Header.Accept]: this._defaultAccept,
+ },
+ timeout: {
+ request: this.options.timeout || 120000,
+ },
+ hooks: {
+ beforeRetry: [
+ this._onRetry,
+ ],
+ },
+ });
+ }
+ if (args.length) {
+ return this.got(...args);
+ }
+ }
+
+
+ /**
+ * Take notes on transient retries.
+ * @param {*} error
+ * @param {*} retryCount
+ */
+ _onRetry(error, retryCount) {
+ const _scope = _fileScope('_onRetry');
+ this.logger.debug(_scope, 'retry', { retryCount, error });
}
}
- /**
- * Valid response statuses.
- * Allow 401 as a workaround for one specific client which return such on
- * its client identifier endpoint when not yet authenticated.
- * @param {Number} status
- * @returns {Boolean}
- */
- static _validateStatus(status) {
- return (status >= 200 && status < 300) || status == 401;
- }
-
-
- /**
- * A request config skeleton.
- * @param {String} method
- * @param {URL} urlObj
- * @param {String=} body
- * @param {Object=} params
- * @param {Object=} headers
- * @returns {Object}
- */
- static _axiosConfig(method, urlObj, body, params = {}, headers = {}) {
- const config = {
- method,
- url: `${urlObj.origin}${urlObj.pathname}`,
- params: urlObj.searchParams,
- headers,
- ...(body && { data: body }),
- // Setting this does not appear to be enough to keep axios from parsing JSON response into object
- responseType: 'text',
- // So force the matter by eliding all response transformations
- transformResponse: [ (res) => res ],
-
- validateStatus: Communication._validateStatus,
- };
- Object.entries(params).map(([k, v]) => config.params.set(k, v));
- return config;
- }
-
-
/**
* Isolate the base of a url.
* mf2 parser needs this so that relative links can be made absolute.
mediaType: mediaType.toLowerCase() || defaultContentType,
params: params.reduce((obj, param) => {
const [field, value] = param.split('=');
- const isQuoted = value && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"';
+ const isQuoted = value?.startsWith('"') && value?.endsWith('"');
obj[field.toLowerCase()] = isQuoted ? value.slice(1, value.length - 1) : value;
return obj;
}, {}),
};
let response;
try {
- const fetchMicroformatConfig = Communication._axiosConfig('GET', urlObj);
- response = await this.axios(fetchMicroformatConfig);
+ const fetchMicroformatConfig = {
+ method: 'GET',
+ url: urlObj,
+ responseType: 'buffer',
+ };
+ response = await this.got(fetchMicroformatConfig);
} catch (e) {
this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData });
return;
}
- logInfoData.response = common.axiosResponseLogData(response);
+ logInfoData.response = common.gotResponseLogData(response);
// Normalize to utf8.
- let body = response.data;
+ let body;
const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]);
+ // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8.
const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset;
if (nonUTF8Charset) {
try {
const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore');
- body = iconv.convert(body).toString('utf8');
+ body = iconv.convert(response.body).toString('utf8');
} catch (e) {
// istanbul ignore next
this.logger.error(_scope, 'iconv conversion error', { error: e, ...logInfoData });
// Try to carry on, maybe the encoding will work anyhow...
}
+ } else {
+ body = response.body.toString('utf8');
}
let microformat = {};
};
let response;
try {
- const fetchJSONConfig = Communication._axiosConfig('GET', urlObj, undefined, undefined, {
- [Enum.Header.Accept]: [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', '),
- });
- response = await this.axios(fetchJSONConfig);
+ const fetchJSONConfig = {
+ method: 'GET',
+ url: urlObj,
+ headers: {
+ [Enum.Header.Accept]: this._jsonAccept,
+ },
+ responseType: 'json',
+ };
+ response = await this.got(fetchJSONConfig);
} catch (e) {
this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
return;
}
- logInfoData.response = common.axiosResponseLogData(response);
+ logInfoData.response = common.gotResponseLogData(response);
- let data;
- try {
- data = JSON.parse(response.data);
- } catch (e) {
- this.logger.error(_scope, 'json parsing failed', { error: e, ...logInfoData });
- }
-
- return data;
+ return response.body;
}
async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) {
const _scope = _fileScope('redeemCode');
- const formData = common.formData({
- 'grant_type': 'authorization_code',
- code,
- 'client_id': clientId,
- 'redirect_uri': redirectURI,
- 'code_verifier': codeVerifier,
- });
-
- const postRedeemCodeConfig = Communication._axiosConfig('POST', urlObj, formData, {}, {
- [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
- [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`,
- });
+ const postRedeemCodeConfig = {
+ url: urlObj,
+ method: 'POST',
+ headers: {
+ [Enum.Header.Accept]: this._jsonAccept,
+ },
+ form: {
+ 'grant_type': 'authorization_code',
+ code,
+ 'client_id': clientId,
+ 'redirect_uri': redirectURI,
+ 'code_verifier': codeVerifier,
+ },
+ responseType: 'json',
+ };
try {
- const response = await this.axios(postRedeemCodeConfig);
- try {
- return JSON.parse(response.data);
- } catch (e) {
- this.logger.error(_scope, 'failed to parse json', { error: e, response });
- throw e;
- }
+ const response = await this.got(postRedeemCodeConfig);
+ return response.body;
} catch (e) {
this.logger.error(_scope, 'redeem code request failed', { error: e, url: urlObj.href });
return;
/**
- * Deprecated method name.
+ * Deprecated method name alias.
* @see redeemCode
* @param {URL} urlObj
* @param {String} code
async introspectToken(introspectionUrlObj, authorizationHeader, token) {
const _scope = _fileScope('introspectToken');
- const formData = common.formData({ token });
- const postIntrospectConfig = Communication._axiosConfig('POST', introspectionUrlObj, formData, {}, {
- [Enum.Header.Authorization]: authorizationHeader,
- [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
- [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`,
- });
- delete postIntrospectConfig.validateStatus; // only accept success
+ const postIntrospectConfig = {
+ url: introspectionUrlObj,
+ method: 'POST',
+ headers: {
+ [Enum.Header.Authorization]: authorizationHeader,
+ [Enum.Header.Accept]: this._jsonAccept,
+ },
+ form: {
+ token,
+ },
+ responseType: 'json',
+ };
- let tokenInfo;
try {
- const response = await this.axios(postIntrospectConfig);
+ const response = await this.got(postIntrospectConfig);
this.logger.debug(_scope, 'response', { response });
// check status
try {
- tokenInfo = JSON.parse(response.data);
const {
active,
me,
scope,
exp,
iat,
- } = tokenInfo;
+ } = response.body;
+
+ if (![true, false].includes(active)) {
+ throw new RangeError('missing required response field "active"');
+ }
return {
active,
...(iat && { iat: Number(iat) }),
};
} catch (e) {
- this.logger.error(_scope, 'failed to parse json', { error: e, response });
+ this.logger.error(_scope, 'failed to parse json', { error: e, response: common.gotResponseLogData(response) });
throw e;
}
} catch (e) {
const _scope = _fileScope('deliverTicket');
try {
- const ticketPayload = {
- ticket,
- resource: resourceUrlObj.href,
- subject: subjectUrlObj.href,
+ const ticketConfig = {
+ method: 'POST',
+ url: ticketEndpointUrlObj,
+ form: {
+ ticket,
+ resource: resourceUrlObj.href,
+ subject: subjectUrlObj.href,
+ },
};
- const ticketConfig = Communication._axiosConfig('POST', ticketEndpointUrlObj, ticketPayload, {}, {
- [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
- });
- return await this.axios(ticketConfig);
+ return await this.got(ticketConfig);
} catch (e) {
this.logger.error(_scope, 'ticket delivery request failed', { error: e, url: ticketEndpointUrlObj.href });
throw e;