'use strict';
const https = require('https');
const http = require('http');
const uuidv4 = require('uuid/v4');
const { EmbeddedDocument } = require('marpat');
const _ = require('lodash');
const { deepMapKeys } = require('../utilities');
const { Connection } = require('./connection.model');
const axios = require('axios');
const axiosCookieJarSupport = require('axios-cookiejar-support').default;
const { omit } = require('../utilities');
const instance = axios.create();
axiosCookieJarSupport(instance);
/**
* @class Agent
* @classdesc The class used to model the axios http instance and agent
*/
class Agent extends EmbeddedDocument {
/** @constructs */
constructor() {
super();
this.schema({
/**
* The global id for an http or https.agent
* @member Agent#global
* @type String
*/
global: {
type: String
},
/**
* The protocol for the client.
* @member Agent#protocol
* @type String
*/
protocol: {
type: String,
required: true,
choices: ['http', 'https']
},
/**
* The client's custom http or https agent.
* @member Agent#agent
* @type String
*/
agent: {
type: Object
},
/**
* maximum amount of concurrent requests to send.
* @member Agent#connection
* @type Class
*/
connection: {
type: Connection,
required: true
},
/**
* maximum amount of concurrent requests to send.
* @member Connection#concurrency
* @type Number
*/
concurrency: {
type: Number,
default: () => 1
},
/**
* requests queued for sending.
* @member Agent#queue
* @type Array
*/
queue: {
type: Array,
default: () => []
},
/**
* requests awaiting responses.
* @member Agent#pending
* @type Array
*/
pending: {
type: Array,
default: () => []
},
/**
* A timeout for requests.
* @member Agent#timeout
* @type String
*/
timeout: {
type: Number
},
/**
* A delay between checking for request responses.
* @member Agent#delay
* @type String
*/
delay: {
type: Number,
default: () => 1
},
/**
* A proxy to use for requests.
* @member Agent#proxy
* @type Object
*/
proxy: {
type: Object
}
});
}
/**
* preInit is a hook
* @schema
* @description The agent preInit hook creates a global agent for the
* client to use if one is required.
* @see _globalize
* @param {Object} data The data used to create the agent.
*/
preInit({ agent, protocol, timeout, concurrency, connection }) {
this.concurrency = concurrency > 0 ? concurrency : 1;
this.connection = Connection.create(connection);
if (agent) this.globalize(protocol, agent);
}
/**
* preDelete is a hook
* @schema
* @description The agent preDelete hook will remove an Agent
* from the global scope when the client is destroyed.
* @param {Object} data The data used to create the client.
*/
preDelete() {
if (
global.FMS_API_CLIENT.AGENTS &&
global.FMS_API_CLIENT.AGENTS[this.global]
) {
this.localize()[`${this.protocol}Agent`].destroy();
delete global.FMS_API_CLIENT.AGENTS[this.global];
}
}
/**
* @method globalize
* @private
* @memberof Agent
* @description globalize will create the global agent scope if it does not
* exist. It will set a global Id for retrieval later and create a new http or
* https module depending on the protocol passed to it.
* @param {String} protocol The protocol to use when creating an Agent.
* @param {Object} agent The name of the script
* @return {Object} returns a globalized request agent
*/
globalize(protocol, agent) {
if (!this.global) this.global = uuidv4();
/**
* @global
*/
global.FMS_API_CLIENT.AGENTS[this.global] =
protocol === 'https'
? {
httpsAgent: new https.Agent(this.agent)
}
: {
httpAgent: new http.Agent(this.agent)
};
return global.FMS_API_CLIENT.AGENTS[this.global];
}
/**
* @method localize
* @private
* @memberof Agent
* @description localize will check to see if a global agent exists.
* If the agent does not exist this method will call _globalize to add
* it.
* @see globalize
* @return {Object} returns a globalized request agent
*/
localize() {
if (typeof global.FMS_API_CLIENT.AGENTS === 'undefined')
global.FMS_API_CLIENT.AGENTS = [];
if (global.FMS_API_CLIENT.AGENTS[this.global]) {
return global.FMS_API_CLIENT.AGENTS[this.global];
} else {
return this.globalize(this.protocol, this.agent);
}
}
/**
* @method request
* @public
* @memberof Agent
* @description request will merge agent properties with request properties
* in order to make the request. This method removes httpAgent and httpsAgents through destructoring.
* @see {@link localize}
* @see {@link push}
* @see {@link handleResponse}
* @see {@link handleRequest}
* @see {@link handleError}
* @param {Object} data The request
* @param {Object} [parameters] The request parameters. Individualized request parameters.
* @return {Object} request The configured axios instance to use for a request.
*/
request(data, parameters = {}) {
instance.interceptors.request.use(
({ httpAgent, httpsAgent, ...request }) =>
new Promise((resolve, reject) =>
this.push({
request: this.handleRequest(request),
resolve,
reject
})
)
);
instance.interceptors.response.use(
response => this.handleResponse(response),
error => this.handleError(error)
);
return instance(
Object.assign(
data,
this.timeout ? { timeout: this.timeout } : {},
_.isEmpty(this.proxy) ? {} : { proxy: this.proxy },
_.isEmpty(this.agent) ? {} : this.localize(),
parameters.request || {}
)
);
}
/**
* @method handleResponse
* @private
* @memberof Agent
* @description handles request data before it is sent to the resource. This function
* will eventually be used to cancel the request and return the configuration body.
* This function will test the url for an http proticol and reject if none exist.
* @param {Object} response The axios response
* @return {Promise} the request configuration object
*/
handleResponse(response) {
if (typeof response.data !== 'object') {
return Promise.reject({
message: 'The Data API is currently unavailable',
code: '1630'
});
} else {
this.connection.extend(response.config.headers.Authorization);
return response;
}
}
/**
* @method handleRequest
* @private
* @memberof Agent
* @description handles request data before it is sent to the resource. This function
* will eventually be used to cancel the request and return the configuration body.
* This function will test the url for an http proticol and reject if none exist.
* @param {Object} config The axios request configuration
* @return {Promise} the request configuration object
*/
handleRequest(config) {
return config.url.startsWith('http')
? omit(config, ['params.request', 'data.request'])
: Promise.reject({
code: '1630',
message: 'The Data API Requires https or http'
});
}
/**
* @method push
* @private
* @memberof Agent
* @description the push method queues requests and begins the request watcher process. This method will also call shift to
* ensure a request is being processed.
* @param {Object} agent The agent request configuration.
* @param {Object} agent.request The agent request object.
* @param {Function} agent.resolve The function to call when the request has been completed.
* @see {@link Agent#watch}
* @see {@link Agent#mutate}
* @see {@link Agent#shift}
*/
push({ request, resolve, reject }) {
this.queue.push({
request: this.mutate(request, (value, key) =>
key.replace(/\./g, '{{dot}}')
),
resolve
});
this.shift();
this.watch(reject);
}
/**
* @method shift
* @private
* @memberof Agent
* @description the shift method will send a request if there are less pending requests than the set limit.
* @see {@link agent#concurrency}
*/
shift() {
if (this.pending.length < this.concurrency) {
this.pending.push(this.queue.shift());
}
}
/**
* @method mutate
* @private
* @memberof Agent
* @description This method is used to modify keys in an object. This method is used by the watch and resolve methods to
* allow request data to be written to the datastore.
* @see {@link Agent#resolve}
* @see {@link Agent#watch}
* @see {@link Conversion Utilities#deepMapKeys}
* @param {Object} request The agent request object.
* @param {Function} mutation The function to upon each key in the request.
* @return {Object} This mutated request
*/
mutate(request, mutation) {
const {
transformRequest,
transformResponse,
adapter,
validateStatus,
...value
} = request;
const modified = request.url.includes('/containers/')
? request
: deepMapKeys(value, mutation);
return {
...modified,
transformRequest,
transformResponse,
adapter,
validateStatus
};
}
/**
* @function handleError
* @public
* @memberof Agent
* @description This function evaluates the error response. This function will substitute
* a non JSON error or a bad gateway status with a JSON code and message error. This
* function will add an expired property to the error response if it recieves a invalid
* token response.
* @param {Object} error The error recieved from the requested resource.
* @return {Promise} A promise rejection containing a code and a message
*/
handleError(error) {
if (error.code) {
return Promise.reject({ code: error.code, message: error.message });
} else if (
error.response.status === 502 ||
typeof error.response.data !== 'object'
) {
return Promise.reject({
message: 'The Data API is currently unavailable',
code: '1630'
});
} else {
if (error.response.data.messages[0].code === '952')
this.connection.clear(
_.get(error, 'response.config.headers.Authorization')
);
return Promise.reject(error.response.data.messages[0]);
}
}
/**
* @method watch
* @private
* @memberof Agent
* @description This method creates a timer to check on the status of queued and resolved requests
* This method will queue and resolve requests based on the number of incoming requests and the availability
* of sessions. This method will resolve requests and create sessions based upon the agent's configured concurrency.
* token response.
* @param {Function} reject The reject function from the promise that initiated the function.
* @see {@link Agent#concurrency}
* @see {@link Connection@available}
*/
watch(reject) {
if (!this.global) this.global = uuidv4();
if (!global.FMS_API_CLIENT.WATCHERS) global.FMS_API_CLIENT.WATCHERS = {};
if (!global.FMS_API_CLIENT.WATCHERS[this.global]) {
const WATCHER = setTimeout(
function watch() {
if (this.queue.length > 0) {
this.shift();
}
if (this.pending.length > 0 && this.connection.ready()) {
this.resolve();
}
if (
!this.connection.available() &&
!this.connection.starting &&
this.connection.sessions.length < this.concurrency
) {
this.connection
.start(!_.isEmpty(this.agent) ? this.localize() : false)
.catch(error => {
this.pending = [];
this.queue = [];
reject(error);
});
}
if (this.queue.length === 0 && this.pending.length === 0) {
clearTimeout(global.FMS_API_CLIENT.WATCHERS[this.global]);
delete global.FMS_API_CLIENT.WATCHERS[this.global];
} else {
setTimeout(watch.bind(this), this.delay);
}
}.bind(this),
this.delay
);
global.FMS_API_CLIENT.WATCHERS[this.global] = WATCHER;
}
}
/**
* @method resolve
* @private
* @memberof Agent
* @description This method resolves requests by sending them to FileMaker for processing. This method will
* resolve requests currently in the pending queue. This method will inject the available session token into the request.
* @see {@link Agent#pending}
* @see {@link Connection@authentication}
*/
resolve() {
const pending = this.pending.shift();
this.connection
.authentication(
Object.assign(
this.mutate(pending.request, (value, key) =>
key.replace(/{{dot}}/g, '.')
)
),
_.isEmpty(this.agent) ? {} : this.localize()
)
.then(request =>
typeof pending.resolve === 'function'
? pending.resolve(request)
: request
);
}
}
module.exports = {
Agent
};