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
}
}