json_api/server.js

import uri from 'urijs'
import axios from 'axios'
import snakeCase from 'lodash/snakeCase'
import get from 'lodash/get'
import { toCase, toSnakeCaseDeep } from '../utils/case_converter'
import { isFormData } from '../utils/form_data'
import { CallBuilder } from '../call_builder'
import {
  NetworkError,
  ClientTimeoutError,
  NotFoundError,
  GatewayTimeoutError,
  ServiceUnavailableError,
  BadGatewayError
} from '../errors'
import * as errors from './errors'
import { JsonApiResponse } from './response'

/**
 * Base class for services following the JSON API spec.
 *
 * @class
 */
export class JsonApiServer {
  /**
   * Create a new Horizon instance.
   *
   * @constructor
   *
   * @param {ShelfNetwork} sdk Parent SDK instance.
   * @param {string} serverUrl Horizon server instance URL.
   * @param {Object} opts
   * @param {string} [opts.alias] Service alias used by the API gateway.
   * @param {boolean} [opts.allowHttp] Allow connecting to http servers, default: `false`. This must be set to false in production deployments!
   * @param {Object} [opts.proxy] Proxy configuration. Look [axios docs](https://github.com/axios/axios#request-config) for more info
   * @param {Object} [opts.httpBasicAuth] HTTP basic auth credentials. Look [axios docs](https://github.com/axios/axios#request-config) for more info.
   * @param {Object} [opts.customHeaders] Custom headers for request.
   * @param {boolean} [opts.withCredentials] Indicates whether or not cross-site Access-Control requests should be made using credentials.
   */
  constructor (sdk, serverUrl, opts = {}) {
    // Sanity checks
    if (!sdk) {
      throw new TypeError('An isntance of ShelfNetwork SDK expected.')
    }
    let parsedUrl = uri.parse(serverUrl)
    if (!(parsedUrl.protocol && parsedUrl.hostname)) {
      throw new Error('Invalid URL')
    }
    if (parsedUrl.protocol !== 'https' && !opts.allowHttp) {
      throw new Error(`
        Secure connection required.
        Set "allowHttp" to true if you are sure that you want to use unprotected connection.
      `)
    }

    this._sdk = sdk
    this._alias = opts.alias
    this._axios = axios.create({
      baseURL: serverUrl,
      proxy: opts.proxy,
      auth: opts.httpBasicAuth,
      headers: opts.customHeaders || axios.defaults.headers,
      withCredentials: opts.withCredentials,
      responseType: 'json'
    })

    this.useRequestInterceptor(
      async (config) => {
        if (config.params) {
          // HACK: replace square braces before _.snakeCase
          // because it interpretes them as separators
          config.params = Object.keys(config.params)
            .map(key => ({ key, value: config.params[key] }))
            .map(kvp => {
              let key = kvp.key.replace('[', 'α').replace(']', 'ω')
              key = toCase(key, snakeCase)
              key = key.replace('α', '[').replace('ω', ']')
              kvp.key = key

              return kvp
            })
            .reduce((acc, kvp) => {
              acc[kvp.key] = kvp.value
              return acc
            }, {})
        }
        if (config.data && !isFormData(config.data)) {
          config.data = toSnakeCaseDeep(config.data)
        }

        return config
      },
      (err) => Promise.reject(err)
    )

    this.useResponseInterceptor(
      (response) => new JsonApiResponse(response, this._sdk),
      (error) => this._parseResponseErrorBase(error)
    )
  }

  /**
   * Use request interceptor.
   * @see [axios.js docs](https://github.com/axios/axios#interceptors)
   *
   * @param {function} handleSuccess Handler for successful requests.
   * @param {function} handleFailure Handler for failed requests.
   *
   * @return {Object} Axios.js interceptor object.
   */
  useRequestInterceptor (handleSuccess, handleFailure) {
    return this._axios.interceptors.request.use(
      handleSuccess,
      handleFailure
    )
  }

  /**
   * Eject request interceptor.
   * @see [axios.js docs](https://github.com/axios/axios#interceptors)
   *
   * @param {Object} interceptor Axios.js interceptor descriptor.
   */
  ejectRequestInterceptor (interceptor) {
    this._axios.interceptors.request.eject(interceptor)
  }

  /**
   * Use response interceptor.
   * @see [axios.js docs](https://github.com/axios/axios#interceptors)
   *
   * @param {function} handleSuccess Handler for successful responses.
   * @param {function} handleFailure Handler for failed responses.
   *
   * @return {Object} Axios.js interceptor object.
   */
  useResponseInterceptor (handleSuccess, handleFailure) {
    return this._axios.interceptors.response.use(
      handleSuccess,
      handleFailure
    )
  }

  /**
   * Eject response interceptor.
   * @see [axios.js docs](https://github.com/axios/axios#interceptors)
   *
   * @param {Object} interceptor Axios.js interceptor descriptor.
   */
  ejectResponseInterceptor (interceptor) {
    this._axios.interceptors.response.eject(interceptor)
  }

  _makeCallBuilder () {
    let builder = new CallBuilder(this._axios, this._sdk)
    if (this._alias) {
      builder.appendUrlSegment(this._alias)
    }
    return builder
  }

  _parseResponseErrorBase (error) {
    if (error.message === 'Network Error') {
      error = new NetworkError('Network error ocurred.', error)
    } else if (error.code === 'ECONNABORTED') {
      error = new ClientTimeoutError(error.message, error)
    } else if (error.response) {
      const contentType = get(error, 'response.headers.content-type')
      if (contentType === 'application/vnd.api+json') {
        error = this._parseJsonApiError(error)
      } else {
        switch (error.response.status) {
          case 404:
            error = new NotFoundError(error, this._axios)
            break
          case 502:
            error = new BadGatewayError(error, this._axios)
            break
          case 503:
            error = new ServiceUnavailableError(error, this._axios)
            break
          case 504:
            error = new GatewayTimeoutError(error, this._axios)
            break
        }
      }
    }

    return Promise.reject(error)
  }

  _parseJsonApiError (error) {
    switch (error.response.status) {
      case 400:
        error = new errors.JsonApiBadRequestError(error, this._axios)
        break
      case 401:
        error = new errors.JsonApiNotAllowedError(error, this._axios)
        break
      case 403:
        error = new errors.JsonApiForbiddenRequestError(error, this._axios)
        break
      case 404:
        error = new errors.JsonApiNotFoundError(error, this._axios)
        break
      case 409:
        error = new errors.JsonApiConflictError(error, this._axios)
        break
      case 500:
        error = new errors.JsonApiInternalServerError(error, this._axios)
        break
      default:
        error = new errors.JsonApiError(error, this._axios)
    }

    return error
  }
}