Manual Reference Source Test

src/modules/identity-server.js

import uuidv4 from '../helpers/uuid';
import {checkIsLocal} from '../helpers/local';

/**
 * Identity server
 *
 * @property {string} env - Environment to use, e.g: dev/stage/..
 * @property {string} idmVersion - Identity server API version
 * @property {string} sdkVersion - Identity SDK version
 * @property {string} timeout - Default request timeout in seconds (0 to disable)
 * @property {string} url - Base URL
 */
class IdentityServer {
  /**
   * IdentityServer constructor
   *
   * @param {Object} [options={}] - Options
   * @param {string} [options.env='production'] - Environment to use, e.g: dev/stage/..
   * @param {string} [options.idmVersion='v2'] - Identity server API version
   * @param {string} [options.sdkVersion='test'] - Identity server API version
   * @param {number} [options.timeout=20] - Default request timeout in seconds (0 to disable)
   */
  constructor({
    env = 'production',
    idmVersion = 'v2',
    useLocalConfig = false,
    sdkVersion = 'test',
    timeout = 20,
  } = {}) {
    Object.assign(this, {
      config: {},
      env,
      idmVersion,
      useLocalConfig,
      sdkVersion,
      timeout,
      url: (useLocalConfig ? 'http://localhost:3000'
      : env === 'production' ? 'https://id.nbc.com' : `https://${env}-id.nbc.com`),
    });
  }

  /**
   * Requests specified path (or URL) and returns a Promise of JSON parsed response body
   *
   * If response is not OK the returned Promise is rejected.
   *
   * If the returned Promise is rejected, the associated Error instance is decorated with status (number),
   * statusText (string) and retry (function) properties.
   * The retry function can be called to retry the request.
   *
   * If a body is specified it will be JSON stringified and a Content-Type: application/json header will be added.
   *
   * If a timeout is specified that will be used, otherwise the default IdentityServer timeout will be used.
   *
   * Besides these options, standard fetch init properties (e.g: method) are supported as options as well.
   * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
   *
   * @param {string} pathOrUrl - Request path or URL
   * @param {Object} [options={}] - Options
   * @param {Object} [options.headers={}] - Request headers
   * @param {Object} [options.body] - Request body
   * @param {number} [options.timeout] - Request timeout in seconds (0 to disable)
   * @return {Promise} - Promise of JSON parsed response body
   */
  request(pathOrUrl, options = {}) {
    const {headers = {}, body, timeout = this.timeout, ...init} = options;
    const retry = () => this.request(pathOrUrl, options);

    // Convert headers
    init.headers = new Headers(headers);

    // Add JSON body and header if necessary
    if (body) {
      init.headers.set('Content-Type', 'application/json');
      init.body = JSON.stringify(body);
    }

    // Add base server url if necessary
    const url = new URL(pathOrUrl, this.url);

    const promise = fetch(url.toString(), init).then((response) => {
      const {ok, status, statusText} = response;

      // Check headers to make sure response is JSON format
      const contentType = response.headers.get('content-type');
      if (!contentType || contentType.indexOf('application/json') === -1) {
        const error = new Error('Response must be JSON');
        throw Object.assign(error, {code: 422});
      }

      return response.json().then((responseBody) => {
        if (ok) {
          return responseBody;
        } else {
          // Reject if response status not OK and decorate error with any responseBody properties
          const error = new Error(`Response not OK (${status} - ${statusText})`);
          throw Object.assign(error, responseBody);
        }
      }).catch((error) => {
        // And always decorate error with status properties and retry function
        throw Object.assign(error, {status, statusText, retry});
      });
    });

    if (!timeout) {
      return promise;
    }

    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // Like above, decorate error with status properties and retry function
        const error = new Error(`Timeout after ${timeout} seconds`);
        Object.assign(error, {
          status: 0,
          statusText: 'Timeout',
          retry,
        });
        reject(error);
      }, timeout * 1000);
      promise.then(resolve, reject);
    });
  }

  /**
   * Returns an Identity Call URL with specified path
   *
   * This defaults to using the server url as base, but uses a special proxy instead when we are cross origin.
   * Note: This proxy currently only supports envs: dev and stage
   * TODO: Get rid of this workaround once Akamai supports adding /index.html and we can use *-id.nbc.com/sdk/
   *
   *
   * @param {string} [path=''] - Identity Call path
   * @return {string} - Identity Call URL with specified path
   */
  getCallUrl(path = '') {
    let base = this.url;
    const local = checkIsLocal();
    if (local||location.origin.endsWith('id.nbc.com-sdk.s3-website-us-east-1.amazonaws.com')) {
      base = `https://us-central1-nbc-id-sdk.cloudfunctions.net/proxy/${this.env}/`;
    }
    return new URL(path, base).toString();
  }

  /**
   * Makes an Identity server API call
   *
   * This adds some standard headers and then requests specified path (e.g: endpoint).
   *
   * See {@link IdentityServer#getSdkUrl} for details on the URL that is used.
   * See {@link IdentityServer#request} for further supported options etc.
   *
   * @param {string} path - Request path
   * @param {Object} [options={}] - Request options
   * @param {Object} [options.headers={}] - Request headers
   * @return {Promise} - Promise of response body
   */
  call(path, {
    headers = {},
    ...options
  } = {}) {
    return this.request(this.getCallUrl(path), {
      headers: {
        'Cache-Control': 'no-cache',
        'idmVersion': this.idmVersion,
        'idm_tx_ref': uuidv4(),
        'X-IDM-Brand-Source': (this.config.showAttributes || {}).IDMBrandSource || '',
        ...headers,
      },
      ...options,
    });
  }

  /**
   * Returns a SDK URL with specified path
   *
   * This defaults to using the server url + /sdk as base, but uses the s3-website instead when we are cross origin.
   *
   * This also adds the SDK version to the URL to serve as a cache buster.
   *
   * See {@link IdentityServer#request} for supported options etc.
   *
   * @param {String} [path=''] - SDK Request path
   * @param {String} [customBase=null] - Custom SDK base URL
   * @return {String} - SDK URL with specified path
   */
  getSdkUrl(path = '', customBase = null) {
    let base = customBase || (this.useLocalConfig ? `${this.url}/public/` : `${this.url}/sdk/`);
    const url = new URL(path, base);
    if (url.searchParams) {
      url.searchParams.set('version', this.sdkVersion);
    } else if (url.search) {
      url.search.set('version', this.sdkVersion);
    }
    return url.toString();
  }

  /**
   * Requests a response from an SDK path
   *
   * See {@link IdentityServer#getSdkUrl} for details on the URL that is used.
   * See {@link IdentityServer#request} for supported options etc.
   *
   * @param {String} path - SDK Request path
   * @param {String} [customBase=null] - Custom SDK base URL
   * @param {Object} [options={}] - Request options
   * @return {Promise} - Promise of JSON parsed response body
   */
  sdk(path, customBase = null, options = {}) {
    return this.request(this.getSdkUrl(path, customBase), options);
  }

  /**
   * Set the config in the server scope
   * @param {Object} config
   */
  setConfig(config) {
    this.config = config;
  }
}

export default IdentityServer;