models/agent.model.js

  1. 'use strict';
  2. const https = require('https');
  3. const http = require('http');
  4. const uuidv4 = require('uuid/v4');
  5. const { EmbeddedDocument } = require('marpat');
  6. const _ = require('lodash');
  7. const { deepMapKeys } = require('../utilities');
  8. const { Connection } = require('./connection.model');
  9. const axios = require('axios');
  10. const axiosCookieJarSupport = require('axios-cookiejar-support').default;
  11. const { omit } = require('../utilities');
  12. const instance = axios.create();
  13. axiosCookieJarSupport(instance);
  14. /**
  15. * @class Agent
  16. * @classdesc The class used to model the axios http instance and agent
  17. */
  18. class Agent extends EmbeddedDocument {
  19. /** @constructs */
  20. constructor() {
  21. super();
  22. this.schema({
  23. /**
  24. * The global id for an http or https.agent
  25. * @member Agent#global
  26. * @type String
  27. */
  28. global: {
  29. type: String
  30. },
  31. /**
  32. * The protocol for the client.
  33. * @member Agent#protocol
  34. * @type String
  35. */
  36. protocol: {
  37. type: String,
  38. required: true,
  39. choices: ['http', 'https']
  40. },
  41. /**
  42. * The client's custom http or https agent.
  43. * @member Agent#agent
  44. * @type String
  45. */
  46. agent: {
  47. type: Object
  48. },
  49. /**
  50. * maximum amount of concurrent requests to send.
  51. * @member Agent#connection
  52. * @type Class
  53. */
  54. connection: {
  55. type: Connection,
  56. required: true
  57. },
  58. /**
  59. * maximum amount of concurrent requests to send.
  60. * @member Connection#concurrency
  61. * @type Number
  62. */
  63. concurrency: {
  64. type: Number,
  65. default: () => 1
  66. },
  67. /**
  68. * requests queued for sending.
  69. * @member Agent#queue
  70. * @type Array
  71. */
  72. queue: {
  73. type: Array,
  74. default: () => []
  75. },
  76. /**
  77. * requests awaiting responses.
  78. * @member Agent#pending
  79. * @type Array
  80. */
  81. pending: {
  82. type: Array,
  83. default: () => []
  84. },
  85. /**
  86. * A timeout for requests.
  87. * @member Agent#timeout
  88. * @type String
  89. */
  90. timeout: {
  91. type: Number
  92. },
  93. /**
  94. * A delay between checking for request responses.
  95. * @member Agent#delay
  96. * @type String
  97. */
  98. delay: {
  99. type: Number,
  100. default: () => 1
  101. },
  102. /**
  103. * A proxy to use for requests.
  104. * @member Agent#proxy
  105. * @type Object
  106. */
  107. proxy: {
  108. type: Object
  109. }
  110. });
  111. }
  112. /**
  113. * preInit is a hook
  114. * @schema
  115. * @description The agent preInit hook creates a global agent for the
  116. * client to use if one is required.
  117. * @see _globalize
  118. * @param {Object} data The data used to create the agent.
  119. */
  120. preInit({ agent, protocol, timeout, concurrency, connection }) {
  121. this.concurrency = concurrency > 0 ? concurrency : 1;
  122. this.connection = Connection.create(connection);
  123. if (agent) this.globalize(protocol, agent);
  124. }
  125. /**
  126. * preDelete is a hook
  127. * @schema
  128. * @description The agent preDelete hook will remove an Agent
  129. * from the global scope when the client is destroyed.
  130. * @param {Object} data The data used to create the client.
  131. */
  132. preDelete() {
  133. if (
  134. global.FMS_API_CLIENT.AGENTS &&
  135. global.FMS_API_CLIENT.AGENTS[this.global]
  136. ) {
  137. this.localize()[`${this.protocol}Agent`].destroy();
  138. delete global.FMS_API_CLIENT.AGENTS[this.global];
  139. }
  140. }
  141. /**
  142. * @method globalize
  143. * @private
  144. * @memberof Agent
  145. * @description globalize will create the global agent scope if it does not
  146. * exist. It will set a global Id for retrieval later and create a new http or
  147. * https module depending on the protocol passed to it.
  148. * @param {String} protocol The protocol to use when creating an Agent.
  149. * @param {Object} agent The name of the script
  150. * @return {Object} returns a globalized request agent
  151. */
  152. globalize(protocol, agent) {
  153. if (!this.global) this.global = uuidv4();
  154. /**
  155. * @global
  156. */
  157. global.FMS_API_CLIENT.AGENTS[this.global] =
  158. protocol === 'https'
  159. ? {
  160. httpsAgent: new https.Agent(this.agent)
  161. }
  162. : {
  163. httpAgent: new http.Agent(this.agent)
  164. };
  165. return global.FMS_API_CLIENT.AGENTS[this.global];
  166. }
  167. /**
  168. * @method localize
  169. * @private
  170. * @memberof Agent
  171. * @description localize will check to see if a global agent exists.
  172. * If the agent does not exist this method will call _globalize to add
  173. * it.
  174. * @see globalize
  175. * @return {Object} returns a globalized request agent
  176. */
  177. localize() {
  178. if (typeof global.FMS_API_CLIENT.AGENTS === 'undefined')
  179. global.FMS_API_CLIENT.AGENTS = [];
  180. if (global.FMS_API_CLIENT.AGENTS[this.global]) {
  181. return global.FMS_API_CLIENT.AGENTS[this.global];
  182. } else {
  183. return this.globalize(this.protocol, this.agent);
  184. }
  185. }
  186. /**
  187. * @method request
  188. * @public
  189. * @memberof Agent
  190. * @description request will merge agent properties with request properties
  191. * in order to make the request. This method removes httpAgent and httpsAgents through destructoring.
  192. * @see {@link localize}
  193. * @see {@link push}
  194. * @see {@link handleResponse}
  195. * @see {@link handleRequest}
  196. * @see {@link handleError}
  197. * @param {Object} data The request
  198. * @param {Object} [parameters] The request parameters. Individualized request parameters.
  199. * @return {Object} request The configured axios instance to use for a request.
  200. */
  201. request(data, parameters = {}) {
  202. instance.interceptors.request.use(
  203. ({ httpAgent, httpsAgent, ...request }) =>
  204. new Promise((resolve, reject) =>
  205. this.push({
  206. request: this.handleRequest(request),
  207. resolve,
  208. reject
  209. })
  210. )
  211. );
  212. instance.interceptors.response.use(
  213. response => this.handleResponse(response),
  214. error => this.handleError(error)
  215. );
  216. return instance(
  217. Object.assign(
  218. data,
  219. this.timeout ? { timeout: this.timeout } : {},
  220. _.isEmpty(this.proxy) ? {} : { proxy: this.proxy },
  221. _.isEmpty(this.agent) ? {} : this.localize(),
  222. parameters.request || {}
  223. )
  224. );
  225. }
  226. /**
  227. * @method handleResponse
  228. * @private
  229. * @memberof Agent
  230. * @description handles request data before it is sent to the resource. This function
  231. * will eventually be used to cancel the request and return the configuration body.
  232. * This function will test the url for an http proticol and reject if none exist.
  233. * @param {Object} response The axios response
  234. * @return {Promise} the request configuration object
  235. */
  236. handleResponse(response) {
  237. if (typeof response.data !== 'object') {
  238. return Promise.reject({
  239. message: 'The Data API is currently unavailable',
  240. code: '1630'
  241. });
  242. } else {
  243. this.connection.extend(response.config.headers.Authorization);
  244. return response;
  245. }
  246. }
  247. /**
  248. * @method handleRequest
  249. * @private
  250. * @memberof Agent
  251. * @description handles request data before it is sent to the resource. This function
  252. * will eventually be used to cancel the request and return the configuration body.
  253. * This function will test the url for an http proticol and reject if none exist.
  254. * @param {Object} config The axios request configuration
  255. * @return {Promise} the request configuration object
  256. */
  257. handleRequest(config) {
  258. return config.url.startsWith('http')
  259. ? omit(config, ['params.request', 'data.request'])
  260. : Promise.reject({
  261. code: '1630',
  262. message: 'The Data API Requires https or http'
  263. });
  264. }
  265. /**
  266. * @method push
  267. * @private
  268. * @memberof Agent
  269. * @description the push method queues requests and begins the request watcher process. This method will also call shift to
  270. * ensure a request is being processed.
  271. * @param {Object} agent The agent request configuration.
  272. * @param {Object} agent.request The agent request object.
  273. * @param {Function} agent.resolve The function to call when the request has been completed.
  274. * @see {@link Agent#watch}
  275. * @see {@link Agent#mutate}
  276. * @see {@link Agent#shift}
  277. */
  278. push({ request, resolve, reject }) {
  279. this.queue.push({
  280. request: this.mutate(request, (value, key) =>
  281. key.replace(/\./g, '{{dot}}')
  282. ),
  283. resolve
  284. });
  285. this.shift();
  286. this.watch(reject);
  287. }
  288. /**
  289. * @method shift
  290. * @private
  291. * @memberof Agent
  292. * @description the shift method will send a request if there are less pending requests than the set limit.
  293. * @see {@link agent#concurrency}
  294. */
  295. shift() {
  296. if (this.pending.length < this.concurrency) {
  297. this.pending.push(this.queue.shift());
  298. }
  299. }
  300. /**
  301. * @method mutate
  302. * @private
  303. * @memberof Agent
  304. * @description This method is used to modify keys in an object. This method is used by the watch and resolve methods to
  305. * allow request data to be written to the datastore.
  306. * @see {@link Agent#resolve}
  307. * @see {@link Agent#watch}
  308. * @see {@link Conversion Utilities#deepMapKeys}
  309. * @param {Object} request The agent request object.
  310. * @param {Function} mutation The function to upon each key in the request.
  311. * @return {Object} This mutated request
  312. */
  313. mutate(request, mutation) {
  314. const {
  315. transformRequest,
  316. transformResponse,
  317. adapter,
  318. validateStatus,
  319. ...value
  320. } = request;
  321. const modified = request.url.includes('/containers/')
  322. ? request
  323. : deepMapKeys(value, mutation);
  324. return {
  325. ...modified,
  326. transformRequest,
  327. transformResponse,
  328. adapter,
  329. validateStatus
  330. };
  331. }
  332. /**
  333. * @function handleError
  334. * @public
  335. * @memberof Agent
  336. * @description This function evaluates the error response. This function will substitute
  337. * a non JSON error or a bad gateway status with a JSON code and message error. This
  338. * function will add an expired property to the error response if it recieves a invalid
  339. * token response.
  340. * @param {Object} error The error recieved from the requested resource.
  341. * @return {Promise} A promise rejection containing a code and a message
  342. */
  343. handleError(error) {
  344. if (error.code) {
  345. return Promise.reject({ code: error.code, message: error.message });
  346. } else if (
  347. error.response.status === 502 ||
  348. typeof error.response.data !== 'object'
  349. ) {
  350. return Promise.reject({
  351. message: 'The Data API is currently unavailable',
  352. code: '1630'
  353. });
  354. } else {
  355. if (error.response.data.messages[0].code === '952')
  356. this.connection.clear(
  357. _.get(error, 'response.config.headers.Authorization')
  358. );
  359. return Promise.reject(error.response.data.messages[0]);
  360. }
  361. }
  362. /**
  363. * @method watch
  364. * @private
  365. * @memberof Agent
  366. * @description This method creates a timer to check on the status of queued and resolved requests
  367. * This method will queue and resolve requests based on the number of incoming requests and the availability
  368. * of sessions. This method will resolve requests and create sessions based upon the agent's configured concurrency.
  369. * token response.
  370. * @param {Function} reject The reject function from the promise that initiated the function.
  371. * @see {@link Agent#concurrency}
  372. * @see {@link Connection@available}
  373. */
  374. watch(reject) {
  375. if (!this.global) this.global = uuidv4();
  376. if (!global.FMS_API_CLIENT.WATCHERS) global.FMS_API_CLIENT.WATCHERS = {};
  377. if (!global.FMS_API_CLIENT.WATCHERS[this.global]) {
  378. const WATCHER = setTimeout(
  379. function watch() {
  380. if (this.queue.length > 0) {
  381. this.shift();
  382. }
  383. if (this.pending.length > 0 && this.connection.ready()) {
  384. this.resolve();
  385. }
  386. if (
  387. !this.connection.available() &&
  388. !this.connection.starting &&
  389. this.connection.sessions.length < this.concurrency
  390. ) {
  391. this.connection
  392. .start(!_.isEmpty(this.agent) ? this.localize() : false)
  393. .catch(error => {
  394. this.pending = [];
  395. this.queue = [];
  396. reject(error);
  397. });
  398. }
  399. if (this.queue.length === 0 && this.pending.length === 0) {
  400. clearTimeout(global.FMS_API_CLIENT.WATCHERS[this.global]);
  401. delete global.FMS_API_CLIENT.WATCHERS[this.global];
  402. } else {
  403. setTimeout(watch.bind(this), this.delay);
  404. }
  405. }.bind(this),
  406. this.delay
  407. );
  408. global.FMS_API_CLIENT.WATCHERS[this.global] = WATCHER;
  409. }
  410. }
  411. /**
  412. * @method resolve
  413. * @private
  414. * @memberof Agent
  415. * @description This method resolves requests by sending them to FileMaker for processing. This method will
  416. * resolve requests currently in the pending queue. This method will inject the available session token into the request.
  417. * @see {@link Agent#pending}
  418. * @see {@link Connection@authentication}
  419. */
  420. resolve() {
  421. const pending = this.pending.shift();
  422. this.connection
  423. .authentication(
  424. Object.assign(
  425. this.mutate(pending.request, (value, key) =>
  426. key.replace(/{{dot}}/g, '.')
  427. )
  428. ),
  429. _.isEmpty(this.agent) ? {} : this.localize()
  430. )
  431. .then(request =>
  432. typeof pending.resolve === 'function'
  433. ? pending.resolve(request)
  434. : request
  435. );
  436. }
  437. }
  438. module.exports = {
  439. Agent
  440. };