Home Manual Reference Source

src/Maps4News.js

/*
 * BSD 3-Clause License
 *
 * Copyright (c) 2018, MapCreator
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *  Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 *
 *  Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 *
 *  Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import axios from 'axios';

import { Enum } from './enums';
import DummyFlow from './oauth/DummyFlow';
import OAuth from './oauth/OAuth';
import OAuthToken from './oauth/OAuthToken';
import GeoResourceProxy from './proxy/GeoResourceProxy';
import ResourceProxy from './proxy/ResourceProxy';
import SimpleResourceProxy from './proxy/SimpleResourceProxy';
import ResourceCache from './ResourceCache';
import {
  Choropleth,
  Color,
  Contract,
  Dimension,
  DimensionSet,
  Faq,
  Feature,
  Font,
  FontFamily,
  Highlight,
  InsetMap,
  Job,
  JobShare,
  JobType,
  Language,
  Layer,
  Mapstyle,
  MapstyleSet,
  Notification,
  Organisation,
  Permission,
  PlaceName,
  Role,
  Svg,
  SvgSet,
  Tag,
  User,
} from './resources';
import ResourceBase from './resources/base/ResourceBase';
import Injectable from './traits/Injectable';
import { fnv32b } from './utils/hash';
import Logger from './utils/Logger';
import { isParentOf, mix } from './utils/reflection';
import { custom3xxHandler, retry429ResponseInterceptor, transformAxiosErrors } from './utils/requests';

/**
 * Base API class
 *
 * @mixes Injectable
 */
export default class Maps4News extends mix(null, Injectable) {
  /**
   * @param {OAuth|string} auth - Authentication flow
   * @param {string} host - Remote API host
   */
  constructor (auth = new DummyFlow(), host = process.env.HOST) {
    super();

    if (typeof auth === 'string') {
      const token = auth;

      auth = new DummyFlow();

      auth.token = new OAuthToken(token, 'Bearer', new Date('2100-01-01T01:00:00'), ['*']);
    }

    this.auth = auth;
    this.host = host;
    this.autoLogout = true;

    const bool = str => String(str).toLowerCase() === 'true';

    /**
     * Defaults for common parameters. These are populated during the build process using the `.env` file.
     * @type {{cacheSeconds: number, shareCache: boolean, autoUpdateSharedCache: boolean, dereferenceCache: boolean}}
     */
    this.defaults = {
      cacheSeconds: Number(process.env.CACHE_SECONDS),
      shareCache: bool(process.env.CACHE_SHARED),
      autoUpdateSharedCache: bool(process.env.CACHE_SHARED_AUTO_UPDATE),
      dereferenceCache: bool(process.env.CACHE_DEREFERENCE_OUTPUT),
    };

    this._cache = new ResourceCache(this.defaults.cacheSeconds, this.defaults.dereferenceCache);
    this._logger = new Logger(process.env.LOG_LEVEL);
  }

  /**
   * Get api version
   * @returns {string} - Api version
   * @constant
   */
  get version () {
    return 'v1';
  }

  /**
   * Get the shared cache instance
   * @returns {ResourceCache} - Shared cache instance
   */
  get cache () {
    return this._cache;
  }

  /**
   * Get authentication provider instance
   * @returns {OAuth} - OAuth instance
   */
  get auth () {
    return this._auth;
  }

  /**
   * Get logger instance
   * @returns {Logger} - Logger instance
   */
  get logger () {
    return this._logger;
  }

  /**
   * Set authentication provider instance
   * @param {OAuth} value -- OAuth instance
   */
  set auth (value) {
    if (!isParentOf(OAuth, value)) {
      throw new TypeError('auth must be an instance of OAuth');
    }

    this._auth = value;
  }

  /**
   * Test if the client is authenticated with the api and has a valid token
   * @returns {boolean} - If the client is authenticated with the api
   */
  get authenticated () {
    return this.auth.authenticated;
  }

  /**
   * The current host
   * @returns {string} - The current host
   */
  get host () {
    return this._host;
  }

  /**
   * The remote host
   * @param {string} value - A valid url
   */
  set host (value) {
    value = value.replace(/\/+$/, '');
    this._host = value;
    this.auth.host = value;
  }

  /**
   * Saves the session token so that it can be recovered at a later time. The wrapper can
   * find the token most of the time if the name parameter is left blank.
   * @param {string?} name - name of the token
   */
  saveToken (name) {
    this.auth.token.save(name);
  }

  /**
   * Authenticate with the api using the authentication method provided.
   * @returns {Promise<Maps4News>} - current instance
   * @throws {OAuthError}
   * @throws {ApiError}
   */
  async authenticate () {
    await this.auth.authenticate();

    return this;
  }

  /**
   * Pre-configured Axios instance
   * @return {AxiosInstance} - Axios instance
   */
  get axios () {
    if (this._axios) {
      return this._axios;
    }

    const instance = axios.create({
      baseURL: `${this.host}/${this.version}/`,
      responseType: 'json',
      responseEncoding: 'utf8',
      timeout: 30000, // 30 seconds
      maxRedirects: 0,
    });

    // I feel ashamed for this hack
    // For some reason headers is a reference even when passing custom headers in the instance options
    instance.defaults.headers = JSON.parse(JSON.stringify(instance.defaults.headers));

    instance.defaults.headers.common.Accept = 'application/json';

    instance.defaults.headers.post['Content-Type'] = 'application/json';
    instance.defaults.headers.put['Content-Type'] = 'application/json';
    instance.defaults.headers.patch['Content-Type'] = 'application/json';

    if (this.authenticated) {
      instance.defaults.headers.common.Authorization = this.auth.token.toString();
    }

    if (['xhrAdapter', ''].includes(instance.defaults.adapter.name)) {
      // The xhrAdapter does not support catching redirects, so we
      // can't strip the Authentication header during a redirect.
      instance.defaults.headers.common['X-No-CDN-Redirect'] = 'true';
    } else {
      // Intercept 3xx redirects and rewrite headers
      instance.interceptors.response.use(null, custom3xxHandler);
    }

    // Retry requests if rate limiter is hit
    instance.interceptors.response.use(null, retry429ResponseInterceptor);

    // Transform errors
    instance.interceptors.response.use(null, transformAxiosErrors);

    this._axios = instance;

    return instance;
  }

  /**
   * Static proxy generation
   * @param {string|function} Target - Constructor or url
   * @param {function?} Constructor - Constructor for a resource that the results should be cast to
   * @returns {ResourceProxy} - A proxy for accessing the resource
   * @example
   * api.static('/custom/resource/path/{id}/').get(123);
   *
   * @example
   * class FooBar extends ResourceBase {
   *    static get resourceName() {
   *      return 'custom';
   *    }
   * }
   *
   * api.static(FooBar)
   *   .get(1)
   *   .then(console.log);
   *
   * api.static('/foo-bar-custom', FooBar).lister();
   */
  static (Target, Constructor = ResourceBase) {
    if (typeof Target === 'string') {
      const path = Target;
      const name = Constructor.name || 'AnonymousResource';

      Target = class AnonymousResource extends Constructor {
        static get resourceName () {
          return Object.getPrototypeOf(this).resourceName || 'anonymous';
        }

        static get resourcePath () {
          return path;
        }
      };

      Object.defineProperty(Target, 'name', {
        value: `${name}_${fnv32b(path)}`,
      });
    }

    if (isParentOf(ResourceBase, Target)) {
      return new ResourceProxy(this, Target);
    }

    throw new TypeError('Expected Target to be of type string and Constructor to be a ResourceBase constructor');
  }

  /**
   * Choropleth accessor
   * @see {@link Choropleth}
   * @returns {GeoResourceProxy} - A proxy for accessing the resource
   */
  get choropleths () {
    return new GeoResourceProxy(this, Choropleth, null, {}, {});
  }

  /**
   * Color accessor
   * @see {@link Color}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get colors () {
    return this.static(Color);
  }

  /**
   * Tag accessor
   * @see {@link Tag}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get tags () {
    return this.static(Tag);
  }

  /**
   * Contract accessor
   * @see {@link Contract}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get contracts () {
    return this.static(Contract);
  }

  /**
   * Dimension accessor
   * @see {@link Dimension}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get dimensions () {
    return this.static(Dimension);
  }

  /**
   * Dimension set accessor
   * @see {@link DimensionSet}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get dimensionSets () {
    return this.static(DimensionSet);
  }

  /**
   * Faq accessor
   * @see {@link Faq}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get faqs () {
    return this.static(Faq);
  }

  /**
   * Feature accessor
   * @see {@link Feature}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get features () {
    return this.static(Feature);
  }

  /**
   * Featured jobs accessor
   * @see {@link Job}
   * @returns {SimpleResourceProxy} - A proxy for accessing the resource
   */
  get featuredMaps () {
    return new SimpleResourceProxy(this, Job, '/jobs/featured');
  }

  /**
   * Font accessor
   * @see {@link Font}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get fonts () {
    return this.static(Font);
  }

  /**
   * FontFamily accessor
   * @see {@link FontFamily}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get fontFamilies () {
    return this.static(FontFamily);
  }

  /**
   * Highlight accessor
   * @see {@link Highlight}
   * @returns {GeoResourceProxy} - A proxy for accessing the resource
   */
  get highlights () {
    return new GeoResourceProxy(this, Highlight, null, {}, {});
  }

  /**
   * InsetMap accessor
   * @see {@link InsetMap}
   * @returns {GeoResourceProxy} - A proxy for accessing the resource
   */
  get insetMaps () {
    return new GeoResourceProxy(this, InsetMap, null, {}, {});
  }

  /**
   * Job accessor
   * @see {@link Job}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get jobs () {
    return this.static(Job);
  }

  /**
   * JobShare accessor
   * @see {@link JobShare}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get jobShares () {
    return this.static(JobShare);
  }

  /**
   * JobType accessor
   * @see {@link JobType}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get jobTypes () {
    return this.static(JobType);
  }

  /**
   * Language accessor
   * @see {@link Language}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get languages () {
    return this.static(Language);
  }

  /**
   * Layer accessor
   * @see {@link Layer}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get layers () {
    return this.static(Layer);
  }

  /**
   * Mapstyle accessor
   * @see {@link Mapstyle}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get mapstyles () {
    return this.static(Mapstyle);
  }

  /**
   * MapstyleSet accessor
   * @see {@link MapstyleSet}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get mapstyleSets () {
    return this.static(MapstyleSet);
  }

  /**
   * Notification accessor
   * @see {@link Notification}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get notifications () {
    return this.static(Notification);
  }

  /**
   * Organisation accessor
   * @see {@link Organisation}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get organisations () {
    return this.static(Organisation);
  }

  /**
   * Permission accessor
   * @see {@link Permission}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get permissions () {
    return this.static(Permission);
  }

  /**
   * Role accessor
   * @see {@link Role}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get roles () {
    return this.static(Role);
  }

  /**
   * PlaceName accessor
   * @see {@link PlaceName}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get placeNames () {
    return this.static(PlaceName);
  }

  /**
   * Svg accessor
   * @see {@link Svg}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get svgs () {
    return this.static(Svg);
  }

  /**
   * SvgSet accessor
   * @see {@link SvgSet}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get svgSets () {
    return this.static(SvgSet);
  }

  /**
   * User accessor
   * @see {@link User}
   * @returns {ResourceProxy} - A proxy for accessing the resource
   */
  get users () {
    return this.static(User);
  }

  /**
   * Get SVG set types
   * @see {@link SvgSet}
   * @async
   * @returns {Promise<Enum>} - Contains all the possible SVG set types
   * @throws {ApiError}
   * @deprecated Use getSvgSetTypes
   * @todo Remove
   */
  getSvgSetType () {
    return this.getSvgSetTypes();
  }

  /**
   * Get SVG set types
   * @see {@link SvgSet}
   * @returns {Promise<Enum>} - Contains all the possible SVG set types
   * @throws {ApiError}
   */
  async getSvgSetTypes () {
    const { data: { data } } = await this.axios.get('/svgs/sets/types');

    return new Enum(data, true);
  }

  /**
   * Get font styles
   * @see {@link Font}
   * @returns {Promise<Enum>} - Contains all the possible font styles
   * @throws {ApiError}
   */
  async getFontStyles () {
    const { data: { data } } = await this.axios.get('/fonts/styles');

    return new Enum(data, true);
  }

  /**
   * Forget the current session
   * This will clean up any stored OAuth states stored using {@link StateContainer} and any OAuth tokens stored
   * @async
   */
  logout () {
    return this.auth.logout();
  }

  /**
   * Get if the api should automatically call logout when it counters an AuthenticationException
   * @returns {boolean} - Auto logout
   * @see {@link logout}
   */
  get autoLogout () {
    return this._autoLogout;
  }

  /**
   * Set if the api should automatically call logout when it counters an AuthenticationException
   * @param {boolean} value - Auto logout
   * @see {@link logout}
   */
  set autoLogout (value) {
    this._autoLogout = Boolean(value);
  }
}