Home Reference Source Test Repository

lib/classes/OEmbedProvider.js

'use strict';

/** @ignore */
let querystring = require('querystring');

/** @ignore */
let request = require('request');

/** @ignore */
let fs = require('fs');

/** @ignore */
let UnexpectedStatusError = require('./errors/UnexpectedStatusError');

/** @ignore */
let URLEmbedProvider = require('./URLEmbedProvider');

/** @ignore */
let xml2js = require('xml2js');

/**
* Converts a pattern of URLs into markup via an oembed provider
* @see http://oembed.com
* @extends {URLEmbedProvider}
* @interface
*/
class OEmbedProvider extends URLEmbedProvider {

  /**
  * @param {String} providerURL - the URL of the provider's oembed service
  * @param {Array<RegExp>} urlPatterns - array of regular expressions that this provider will match
  * @param {String} format - the data format of the provider's oembed service. Must be either 'json' or 'xml'.
  */
  constructor (providerURL, urlPatterns, format) {
    super(urlPatterns);

    /**
    * The URL of the provider's oembed service
    * @type {String}
    */
    if (providerURL) this.providerURL = providerURL;

    /**
    * @desc The data format of the provider's oembed service.
    * @type {String}
    */
    if (format) this.format = format;

    /**
    * The version number of the url-embed module
    * @type {String}
    */
    this.version = null;
  }

  /**
  * Resolves options.embedURL to an embed and passes it to callback.
  * @param {Embed} embed - Embed object
  * @param {function(embed:Embed)} callback - callback to invoke after resolving embed
  * @throws {UnexpectedStatusError} throws when provider API returns a non-200 response.
  * @override
  */
  getEmbed (embed, callback) {
    this.makeAPIRequest(embed, function (embed) {
      callback(embed);
    });
  }

  /**
  * Makes the requests to the oembed provider's API
  * @param {Embed} embed - embed object
  * @param {function(embed:Ebmed)} callback - callback to invoke after resolving embed
  */
  makeAPIRequest (embed, callback) {
    let self = this;
    let requestOptions = self.buildRequestOptions(embed);
    let oembedAPIURL = self.buildAPIURL(embed, requestOptions);
    requestOptions.url = oembedAPIURL;

    self.request(requestOptions, function (error, response, body) {
      if (response && response.request && response.request.uri && response.request.uri.href) {
        embed.oembedAPIURL = response.request.uri.href;
      } else {
        embed.oembedAPIURL = oembedAPIURL;
      }

      if (!error && response.statusCode === 200) {
        self.parseResponseBody(body, function (error, data) {
          // json or xml parsing error
          if (error) {
            self._applyErrorToEmbed(error, embed);
            callback(embed);
            return;
          // successfuly parsed
          } else {
            embed.data = data;
            self.filterData(embed.data);
            callback(embed);
            return;
          }
        });
      } else {
        // Error making request
        if (error) {
          self._applyErrorToEmbed(error, embed);
          callback(embed);
          return;
        // No error, but non-200 HTTP response
        } else {
          let errorMarkupFunctionName = 'errorMarkup' + response.statusCode;
          let errorMarkupFunction = self[errorMarkupFunctionName] ? self[errorMarkupFunctionName] : self.errorMarkup;
          let error = new UnexpectedStatusError('HTTP status ' + response.statusCode + ' for embed provider URL: ' + oembedAPIURL);
          error.status = response.statusCode;
          self._applyErrorToEmbed(error, embed, errorMarkupFunction);
          callback(embed);
          return;
        }
      }
    });
  }

  /**
  * Puts the Embed into an error state if an error occurs
  * @param {Error} error - the Error that occurred
  * @param {Embed} embed - the Embed object
  * @param {function(embed: Embed, error: Error)} [errorMarkupFunction] - function that generates error markup
  */
  _applyErrorToEmbed (error, embed, errorMarkupFunction) {
    if (!errorMarkupFunction) {
      errorMarkupFunction = this.errorMarkup;
    }
    embed.data.html = errorMarkupFunction(embed, error);
    embed.error = error;
  }

  /**
  * Parses the provider API response into a data object
  * @param {String} body - API response
  * @param {function(error: Error, data: Object)} callback - callback to invoke with resulting oembed data
  */
  parseResponseBody (body, callback) {
    body = this.convertHighBitUnicodeToSurrogates(body);
    let data;
    if (this.format === 'json') {
      try {
        data = JSON.parse(body);
        callback(null, data);
      } catch (error) {
        callback(error);
      }
      return;
    } else {
      let xmlOptions = {
        explicitArray: false,
        valueProcessors: [
          xml2js.processors.parseNumbers,
          xml2js.processors.parseBooleans
        ]
      };
      data = xml2js.parseString(body, xmlOptions, function (error, result) {
        if (error) {
          callback(error);
        } else {
          data = result.oembed;
          callback(null, data);
        }
        return;
      });
    }
  }

  /**
  * Builds the API URL string
  * @param {Embed} embed - Embed object
  * @return {String} - API URL
  */
  buildAPIURL (embed) {
    let qs = {};
    qs.url = embed.embedURL;
    qs.format = this.format;
    if (embed.options.maxWidth) qs.maxwidth = embed.options.maxWidth;
    if (embed.options.maxHeight) qs.maxheight = embed.options.maxHeight;

    if (this.defaultProviderQueryStringParameters) {
      for (let name in this.defaultProviderQueryStringParameters) {
        qs[name] = this.defaultProviderQueryStringParameters[name];
      }
    }

    return this.providerURL + '?' + querystring.stringify(qs);
  }

  /**
  * Builds the HTTP request options (headers, etc.)
  * @see https://www.npmjs.com/package/request
  * @param {Embed} embed - Embed object
  * @return {Object} - request options
  */
  buildRequestOptions (embed) {
    if (!this.version) {
      let fileContents = fs.readFileSync(__dirname + '/../../package.json');
      let packageJSON = JSON.parse(fileContents);
      this.version = packageJSON.version;
    }

    let requestOptions = {
      headers: {
        'User-Agent': 'URLEmbed Module HTTP Agent ' + this.version
      },
      timeout: this.timeoutMs
    };
    return requestOptions;
  }

  /**
  * Converts high-bit-value escaped unicode code points to unicode surrogate pair code points.
  *
  * JSON.parse yields a parse error when it encounters unicode literals > 16-bits.
  * Notably, many newer emojis fall into this category.
  *
  * To solve for this you need to split the > 16-bit unicode escapedcode point into surrogate pairs.
  *
  * Great and entertaining explaination here:
  * {@link https://gist.github.com/mranney/1707371}
  *
  * Algorithm for calculating surrogate pairs cribbed from here:
  * {@link http://www.russellcottrell.com/greek/utilities/surrogatepaircalculator.htm}
  *
  * @param {String} body - The raw body of the HTTP response from the oembed provider's API
  * @return {String} - The transformed body with surrogate pairs
  */
  convertHighBitUnicodeToSurrogates (body) {
    return body.replace(/\\U([\da-f]{8})/gm, function (match, scalarVal) {
      let S = parseInt('0x' + scalarVal, 16);
      let H = Math.floor((S - 0x10000) / 0x400) + 0xD800;
      let L = ((S - 0x10000) % 0x400) + 0xDC00;
      return '\\u' + H.toString(16) + '\\u' + L.toString(16);
    });
  }

  /**
  * Configures the provider
  * @param {Object} configOptions
  * @param {number} configOptions.timeoutMs - Request timeout in milliseconds.
  * @override
  */
  configure (configOptions) {
    if (configOptions && configOptions.timeoutMs) {
      this.timeoutMs = configOptions.timeoutMs;
    }
  }
}

/**
* The data format of the provider's oembed service. Defaults to 'json'
* @identifier format
* @type {String}
*/
OEmbedProvider.prototype.format = 'json';

/**
* Default request timeout in milliseconds. Defaults to 2000.
* @type {number}
* @identifier timeoutMs
*/
OEmbedProvider.prototype.timeoutMs = 2000;

/**
* Reference to the request module.
* @type {Object}
* @see https://www.npmjs.com/package/request
*/
OEmbedProvider.prototype.request = request;

/**
* Object containing any any odd querystring parameters that the provider's oembed service requires.
* @type {Object}
*/
OEmbedProvider.prototype.defaultProviderQueryStringParameters = null;

module.exports = OEmbedProvider;