switch from axios to got package for client requests
[squeep-indieauth-helper] / lib / communication.js
index ce2bc4e25b8bc05b281b86f970cd1e152b30c35c..18a6e588070e39bb49790433885189703a3bc5d0 100644 (file)
@@ -1,11 +1,9 @@
 '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);
@@ -26,6 +24,7 @@ class Communication {
   /**
    * @param {Console} logger
    * @param {Object} options
+   * @param {Number=} options.timeout
    * @param {Object=} options.userAgent
    * @param {String=} options.userAgent.product
    * @param {String=} options.userAgent.version
@@ -34,20 +33,52 @@ class Communication {
   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 });
   }
 
 
@@ -135,46 +166,6 @@ class Communication {
   }
 
 
-  /**
-   * 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.
@@ -208,7 +199,7 @@ class Communication {
       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;
       }, {}),
@@ -284,27 +275,34 @@ class Communication {
     };
     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 = {};
@@ -340,24 +338,22 @@ class Communication {
     };
     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;
   }
 
 
@@ -760,27 +756,25 @@ class Communication {
   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;
@@ -789,7 +783,7 @@ class Communication {
 
 
   /**
-   * Deprecated method name.
+   * Deprecated method name alias.
    * @see redeemCode
    * @param {URL} urlObj
    * @param {String} code
@@ -812,21 +806,24 @@ class Communication {
   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,
@@ -834,7 +831,11 @@ class Communication {
           scope,
           exp,
           iat,
-        } = tokenInfo;
+        } = response.body;
+
+        if (![true, false].includes(active)) {
+          throw new RangeError('missing required response field "active"');
+        }
 
         return {
           active,
@@ -845,7 +846,7 @@ class Communication {
           ...(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) {
@@ -868,15 +869,16 @@ class Communication {
     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;