communication/index.js

/**
 * This file contains the settings and the communication mechanism for the ITSLanguage REST API.
 *
 * @module api/communication
 */

/**
 * Content-Type HTTP Header key.
 * @type {string}
 */
const CONTENT_TYPE = 'Content-Type';

/**
 * Authorization HTTP Header key.
 * @type {string}
 */
const AUTHORIZATION = 'Authorization';

/**
 * Content-Type HTTP Header value.
 * @type {string}
 */
const APPLICATION_JSON = 'application/json';

/**
 * Link header value.
 * @type {string}
 */
const LINK = 'link';

/**
 * The settings to use for the communication with the ITSLanguage API.
 */
export const settings = {
  apiUrl: 'https://api.itslanguage.nl',
  wsUrl: null,
  authorizationToken: null,
};

/**
 * Update the settings with the `newSettings`.
 *
 * @param {Object} newSettings - The settings to inject/update.
 *
 * @throws {Error} - When the given `newSettings` is something other than a object.
 */
export function updateSettings(newSettings) {
  if (!newSettings || !(newSettings instanceof Object)) {
    throw new Error('Please, only provide objects as settings.');
  }

  Object.assign(settings, newSettings);
}

function parseLinkHeader(linkHeader) {
  const links = linkHeader.split(',');
  return links.reduce((acc, link) => {
    let [url, rel] = link.split(';');
    url = url.replace(/<(.*)>/, '$1').trim();
    rel = rel.replace(/rel="(.*)"/, '$1').trim();
    acc[rel] = {
      url,
    };
    return acc;
  }, {});
}

/**
 * Parse the response of a fetch request.
 *
 * Try to parse the given response body as JSON, if it isn't Leave the response as is.
 *
 * @param {Response} response - The response to parse.
 *
 * @throws {Promise<string>} - When the request wasn't okay and the contents of the response isn't
 * json.
 * @throws {Promise<Error>} - When the request wasn't okay and the contents of the response is json.
 *
 * @returns {Promise|Response} - The contents of a JSON response or the response itself if the body
 * is something other than JSON.
 */
function handleResponse(response) {
  const responseContentType = response.headers.get(CONTENT_TYPE);
  const responseLink = response.headers.get(LINK);
  let parsedLinkHeader = {};

  if (responseLink) {
    parsedLinkHeader = parseLinkHeader(responseLink);
  }

  // The ITSLanguage API should return JSON. If t
  if (responseContentType && responseContentType.includes(APPLICATION_JSON)) {
    return response.json().then(json => {
      if (response.ok) {
        if (responseLink) {
          return {
            data: json,
            ...parsedLinkHeader,
          };
        }
        return json;
      }

      return Promise.reject(json);
    });
  }

  if (!response.ok) {
    return Promise.reject(
      new Error(`${response.status}: ${response.statusText}`),
    );
  }

  return response;
}

/**
 * Perform an HTTP request for the given method, url, body, and headers.
 *
 * In case the given url is a partial url, meaning it starts with a `/`, the base URL to the
 * ITSLanguage API is prepended.
 *
 * When a Object instance is provided as body, it'll be transformed into JSON. Unless it is either a
 * `URLSearchParams` or a `FormData` object. Anything else is sent as plain text.
 *
 * @param {string} method - The request METHOD ('GET', 'POST', 'PUT', 'DELETE').
 * @param {string} url - The location to send the request to.
 * @param {*} [body] - Anything which needs to be sent somewhere.
 * @param {Headers} [headers] - Extra headers to send with the request.
 *
 * @returns {Promise<Object>} The response of the made request.
 */
export function request(method, url, body, headers) {
  const requestHeaders = headers || new Headers();

  let requestBody = body;

  // In case of (any) object-type
  if (
    !(body instanceof URLSearchParams || body instanceof FormData) &&
    body instanceof Object
  ) {
    requestHeaders.set('Content-Type', 'application/json');
    requestBody = JSON.stringify(body);
  }

  // In case of URLSearchParams
  if (body instanceof URLSearchParams) {
    requestHeaders.set('Content-Type', 'application/x-www-form-urlencoded');
    requestBody = body.toString();
  }

  // Prepend the url with the set url or use it complete when there was no url set.
  let requestURL = url;
  if (url.startsWith('/') || !url) {
    requestURL = `${settings.apiUrl}${url}`;
  }

  // Gather all the options
  const requestOptions = {
    method,
    headers: requestHeaders,
    body: requestBody,
  };

  // Use fetch to send the request out!
  return fetch(requestURL, requestOptions).then(handleResponse);
}

/**
 * Build a bearer token from the `authorizationToken` in the settings object.
 *
 * @throws {Error} When no authorizationToken is set.
 *
 * @returns {string} The generated bearer token.
 */
function getBearerToken() {
  if (!settings.authorizationToken) {
    throw new Error('Please authenticate first.');
  }

  return `Bearer ${settings.authorizationToken}`;
}

/**
 * Add an access token to a given URL.
 *
 * @param {string} [url=''] - The URL to add an access token to.
 * @returns {string} An url with the access token appended.
 */
export function addAccessToken(url = '') {
  if (!settings.authorizationToken) {
    return url;
  }

  return `${url}${
    url.includes('?') ? '&' : '?'
  }access_token=${encodeURIComponent(settings.authorizationToken)}`;
}

/**
 * Perform an HTTP request with the desired method, body, and headers to the given partial
 * ITSLanguage API URL.
 *
 * This request will add the ``Authorization`` header to the request.
 *
 * This function only allows to make calls to the ITSLanguage API.
 *
 * @param {string} method - The request METHOD ('GET', 'POST', 'PUT', 'DELETE').
 * @param {string} url - The location to send the request to.
 * @param {*} [body] - Anything which needs to be sent somewhere.
 * @param {Headers} [headers] - Extra headers to send with the request.
 *
 * @throws {Promise<string>} - When the given `url` param is not a partial URL, or when there is no
 * authorisation token available.
 *
 * @returns {Promise<Object>} - The response from the ITSLanguage API.
 */
export function authorisedRequest(method, url, body, headers) {
  // XXX remove the URL truthy check when all parts of the SDK no longer build
  // a complete url by themselves using the "private" settings object of their
  // connection reference.
  if (url && !url.startsWith('/') && !url.startsWith(settings.apiUrl)) {
    return Promise.reject(
      new Error('Only relative ITSLanguage API URLs are allowed.'),
    );
  }

  try {
    const requestHeaders = headers || new Headers();
    requestHeaders.set(AUTHORIZATION, getBearerToken());

    return request(method, url, body, requestHeaders);
  } catch (unauthorised) {
    return Promise.reject(new Error(unauthorised.message));
  }
}