UNPKG

celeritas

Version:

This is an API service framework which supports API requests over HTTP & WebSockets.

482 lines (420 loc) 17.8 kB
'use strict'; const validator = require('validator'); const querystring = require('querystring'); const zlib = require("zlib"); const lodash = require('lodash'); const HttpCall = require('./httpcall.js'); const WebSocketCall = require('./websocketcall.js'); const SelfCall = require('./selfcall.js'); String.prototype.capitalize = function() { return this.replace(/(?:^|\s)\S/g, function(a) { return a.toUpperCase(); }); }; /* @Call: Creates instance of class Call. A Call represents either a Websocket Message which is anticipating a response, an HTTP request, or if the API is internall calling one of its enpoints. arguments: { app: the Celerias app instance. args: Can be null, if it's an internal call. Otherwise, required. { type: "http" or "ws", determines how to fulfill the call. request: if type=="http", it is the Http Request object. response: If type=="http", it is the response object. message: If type=="ws", it is the websocket JSON message. client: If type=="ws", it is the websocket client. } } returns: A Promise. */ class Call { //This constructs the Call object, and executes the API route for that call based on the type of call it is. constructor (app, args) { this.app = app; this.type = args.type || "self"; this.util = args.util; //Depending on the type of call, build it differently. switch (this.type) { case "http": Object.assign(this, new HttpCall(this.app, args.request, args.response, this.util)); break; case "ws": Object.assign(this, new WebSocketCall(this.app, args.message, args.client, this.util)); break; case "self": Object.assign(this, new SelfCall(this.app, args)); break; } //If this API app is using newrelic, set the transaction name and additional custom parameters for this API request try { if (this.app.newrelic) { this.app.newrelic.setTransactionName(this.method + "/" + this.route); this.app.newrelic.addCustomParameters({ RequestId: this.id, RequestType: this.type, Route: this.route, QueryData: this.get, PostData: null, ParamData: this.param, IpAddress: this.ipAddress, CeleritasVersion: this.app._version }); } } catch (err) {} /* For some types of requests, we don't want to log or grab post data, either because it's malformed, it's a health check, or it's a bad URI. */ if (this.status == this.util.ERROR) return this.emit(400, this.error || new Error("Could not complete this request because it is malformed (e.g. if this was a websocket message, it may be missing required fields).")); if (app.blacklist.uris instanceof Array) { if (app.blacklist.uris.indexOf(this.method.toUpperCase() + "/" + this.route) !== -1) { return this.emit(403, new Error(`This request URI has been blacklisted. Do not attempt to repeat this request.`)); } } //Always return a 204 for browser CORS pre-flight requests. if (this.method == "OPTIONS") return this.emit(204); //Always return a 200 for pings to the root directory (until there is a built-in doc system there) if (this.method.toUpperCase() == "GET" && this.route.trim() == "") return this.emit(200); /* We don't want to support API calls reaching the API if they did not get processed through HTTPS. Since we host the API in AWS, the app is actually behind a Load Balancer. The Load Balancer accepts HTTP and HTTPS requests. It then forwards these requests to the app itself, via port 80 (standard HTTP), but it adds a X-Forwarded-Proto header to determine the original protocol. */ if (this.app.server.enforceHttps === true && this.type == "http") { if (this.request.headers['X-Forwarded-Proto'] !== "undefined" && this.request.headers['X-Forwarded-Proto'] == "http") { return this.emit(403, new Error(`This API doesn't support requests made over the HTTP protocol. Please use HTTPS.`)); } if (this.request.headers['x-forwarded-proto'] !== "undefined" && this.request.headers['x-forwarded-proto'] == "http") { return this.emit(403, new Error(`This API doesn't support requests made over the HTTP protocol. Please use HTTPS.`)); } } if (app.logMode.toUpperCase() == "VERBOSE") app._log.info(`PID ${process.pid}: [${this.type.toUpperCase()}] Request ['${this.id}'] - HOST:'${this.type == "http" ? args.request.headers.host : ""}' - ROUTE:'${this.method}/${this.route}' - IP:'${this.ipAddress}'`); return this.init() .then(() => { try { try { //add the post data for newrelic, now that we have that available. if (this.app.newrelic) { this.app.newrelic.addCustomParameters({PostData: this.post}); } } catch (err) {} //If there is no API route for the given URL of the request, return a 404 NOT FOUND. if (!this.app.routes[this.route]) return this.emit(404, new Error("404 - Not Found: API Route '" + this.method + "/" + this.route + "' could not be found. Please try a different route.")); //If the API route for this URL exists, but there is call's method (e.g. GET) is not supported, return 405 METHOD NOT ALLOWED. if (!this.app.routes[this.route][this.method]) return this.emit(404, new Error("405 - Method Not Allowed: API Route '" + this.route + "' does not support method '" + this.method + "'. Please try a different method.")); this.api = this.app.routes[this.route][this.method]; //if the API route is not published, do not allow it to be accessed. if (this.api.published === false) return this.emit(423, new Error(`423 - Locked: This API route is currently unavailable, or has not yet been published for general use.`)); if (this.api.enforceSort === true && typeof this.sort == "string") return this.emit(400, new Error(`400 - Bad Syntax: The sort parameter must be a valid JSON string. '${this.sort}' did not parse as valid JSON.`)) //Only cache for as long as the API route supports, and if the API route doesn't support it, dont cache at all. if (this.api.cache == 0 || this.api.cache < this.cache) this.cache = this.api.cache; //Adjust the return mime type of the API call based on the API method. if (this.returnMimeType != this.api.type) this.returnMimeType = this.api.type; //if this is an http call, check for a timeout property on the route, to override the default timeout time. if (!isNaN(parseInt(this.api.timeout)) && this.type == "http" && typeof this.request.headers['x-timeout'] === "undefined") this.request.setTimeout(this.api.timeout); //The authenticate method returns a true or a User is it is successful. This happens even for public methods, to provide User context to the call. return ((this.api.permissions === null) ? Promise.resolve(true) : this.app.authenticate(this)) .then((user) => { if (user) { this.user = (user !== true) ? user : null; //If authentication is successful, the websocket client (if its ws request) gets a user property of the user. //When a ws client authenticates, assign them all the subscriptions associated for that user. if (this.wsClient) { this.wsClient.user = this.user; if (typeof this.wsClient.user.applySubscriptions == "function") this.wsClient.user.applySubscriptions(this.wsClient); //this.app.replay(this.wsClient); } //validate the request's GET data. return this.util.validate(this.app.validation, `GET/${this.route}`, this.api.get || {}, this.get, "GET") .then((getData) => { //If the GET data does not match the "get" for the API route, return a 400 SYNTAX ERROR if (getData instanceof Error) return this.emit(400, getData, "get"); this.get = getData; //Apply paging defaults if (this.api.paging == true && (typeof this.get.pagesize != "undefined" || typeof this.get.page != "undefined")) this.paging = this.util.assemblePaging(this.get); //validate the request's POST data. return this.util.validate(this.app.validation, `POST/${this.route}`, this.api.post || {}, this.post, "POST") .then((postData) => { //If the POST data does not match the "post" for the API route, return a 400 SYNTAX ERROR if (postData instanceof Error) return this.emit(400, postData, "post"); this.post = postData; if (this.cache != 0) { return this.app.retrieve(this, this.cache) .then((result) => { if (result !== null) { this.output = result.output; return this.emit(result); } else { return new Promise((resolve, reject) => { if (this.app.newrelic && this.app.newrelic.startSegment) { this.app.newrelic.startSegment(this.route, true, async () => { return resolve(this.api.execute(this)); }); } else { return resolve(this.api.execute(this)); } }) } }); } else { return new Promise((resolve, reject) => { if (this.app.newrelic && this.app.newrelic.startSegment) { this.app.newrelic.startSegment(this.route, true, async () => { return resolve(this.api.execute(this)); }); } else { return resolve(this.api.execute(this)); } }) } }) }); } else //If the authenticate returns false, or null, the request is unauthorized, and returns a 401 UNAUTHORIZED. return this.emit(401, new Error("Unauthorized. You are either not logged in, or do not carry sufficient privileges to carry out this action.")); }) } catch (err) { throw err; } }) .then((result) => { if (this.output.code == 204) { if (app.logMode.toUpperCase() == "VERBOSE") app._log.info("PID " + process.pid + ": [" + this.type.toUpperCase() + "] Request ['" + this.id + "'] completed successfully without output (" + this.output.code + ")"); } else if (this.output.code < 300) { if (app.logMode.toUpperCase() == "VERBOSE") app._log.info("PID " + process.pid + ": [" + this.type.toUpperCase() + "] Request ['" + this.id + "'] completed successfully (" + this.output.code + ")"); } else { app._log.error("PID "+ process.pid + ": [" + this.type.toUpperCase() + "] Request ['" + this.id + "'] failed! (" + this.output.code + ")"); if (this.output.data && this.output.data.error) app._log.error(this.output.data.error); } return this; }) .catch(err => {throw err}); } //This method initializes the call. This must occur before the call's API route can be executed, as it prepares stuff like the HTTP post data. async init () { try { if (this.type == "http") { //if the type is HTTP, then some extra work is needed to get the post data from the request. return new Promise(resolve => { var body = []; this.request.on('data', (chunk) => { body.push(chunk); }).on("end", () => { var data = Buffer.concat(body);//.toString(); try { this.rawPost = data.toString(); } catch (err) { this.rawPost = null; } var parse = (data) => { try { data = JSON.parse(data.toString()); } catch (err) { if (typeof data == "string" && data == "") data = {}; } if (data instanceof Buffer) return {}; else return data; } //if (this.compression.input == "gzip") { // zlib.unzip(data, (err, buffer) => { // if (!err) // data = buffer.toString('utf-8'); // resolve(jsonParse(data)); // }); //} //else resolve(parse(data)); }); }) .then((postData) => { this.post = postData; this._rawPost = lodash.cloneDeep(this.post); this.status = this.util.READY; this.app._calls[this.id] = this; return Promise.resolve(this); }) .catch(err => {throw err}); } else { this.status = this.util.READY; this.app._calls[this.id] = this; return Promise.resolve(this); } } catch (err) { return Promise.reject(err); } } //This emits the result of the Call, either as data to be returned, or as a response to the HTTP request or websocket client. emit (statusCode, data, meta = {}) { //if the status code is an object, its a cached response. Otherwise, normal behavior. if (typeof statusCode !== "object") { this.output.code = statusCode; var pagesize = (this.paging && typeof this.paging.limit !== "undefined") ? this.paging.limit : null; var currentpage = (this.paging && pagesize != null && pagesize != 0) ? (this.paging.skip / this.paging.limit)+1 : null; var output = { meta: { requestId: this.id, status: this.output.code, method: this.method, route: this.route, sort: (typeof this.sort == "object" && Object.keys(this.sort).length > 0) ? this.sort : null, paging: { size: pagesize, page: currentpage }, time: { receivedAtUtc: this.timestamp, emittedAtUtc: new Date().toISOString(), elapsedInMS: (Date.now() - Date.parse(this.timestamp)) }, cached: false //UTC timestamp if true. } }; //allow adding any additional meta data properties. The hard-coded data above is immutable, so put custom defined first. output.meta = Object.assign(meta, output.meta); if (typeof this.api == 'undefined') output.meta.paging = null; if (typeof this.api != 'undefined' && (this.api.paging == false || output.meta.paging.size == null || output.meta.paging.page == null)) output.meta.paging = null; if (output.meta.paging != null && typeof this.paging.total !== "undefined") output.meta.paging.total = this.paging.total; if (this.output.code < 400) { if (this.app.api.castResultsAsArray === true) output.result = (data) ? (typeof data == "object" && data instanceof Array ? data : [data]) : []; else output.result = data; } else { if (data instanceof Error) { output.error = { type: data.name, message: data.message, trace: data.stack } } else output.error = data; if (this.app.newrelic) this.app.newrelic.noticeError(output.error); if (this.post && Object.keys(this.post).length > 0) { if (this.app.logMode.toUpperCase() == "VERBOSE") this.app._log.error(`PID ${process.pid}: [${this.type.toUpperCase()}] Request ['${this.id}'] POST: ${this.rawPost}`); } } if (typeof output.error == "undefined" && typeof output.result == "undefined" && this.output.code < 400) output.result = null; if (typeof output.error == "undefined" && typeof output.result == "undefined" && this.output.code >= 400) output.error = null; try { if (this.returnInput == true) { output.meta.get = (this._rawGet) ? this._rawGet : null; output.meta.post = (this._rawPost) ? this._rawPost : null; } } catch (err) { console.error(err); } if (this.api) { var alphabetize = (this.api.alphabetize === true || this.api.alphabetize === false) ? this.api.alphabetize : (this.app.api.alphabetizeResponse === true); this.output.data = (alphabetize) ? this.util._sortReturnData(output) : output; } else this.output.data = output; if (this.output.code < 400 && this.api && this.api.cache > 0) this.app.cache(this); } else { //this data was cached var cachedData = statusCode; this.output.data.meta.cached = new Date(cachedData.from).toISOString(); } switch (this.type) { case "http": return HttpCall.emit(this); case "ws": return WebSocketCall.emit(this); case "self": return SelfCall.emit(this); } } //page will take either an object or array of data, and return it based on current paging rules. paginate (data) { if (typeof data == "object") { if (data instanceof Array) { var ret = []; var finalIndex = this.paging.limit != 0 ? this.paging.skip + this.paging.limit : data.length; for (var i = this.paging.skip; i < finalIndex; i++) ret.push(data[i]); return ret; } else { var ret = {}; var keys = Object.keys(data); var finalIndex = this.paging.limit != 0 ? this.paging.skip + this.paging.limit : keys.length; for (var i = this.paging.skip; i < finalIndex; i++) ret[keys[i]] = data[keys[i]]; return ret; } } else return data; } } module.exports = Call; /* "Call" Anatomy { api: The details of the api object being accessed for this call, status: Call.PENDING|Call.COMPLETE|Call.ERROR method: "POST"|"GET"|"OPTIONS", etc. hostname: Hostname of the url, e.g. "api.example.com" url: HTTP URL e.g. "api.example.com/v1/ping" route: API route, e.g. "users/create" post: post data, get: get data, timestamp: DateTime ipAddress: e.g. 192.168.0.1 returnMimeType: the mime type to return (e.g. application/json) compression: { input: null if the input data is not compessed, otherwise, e.g. "gzip" output: null if the output data should not be compressed, otherwise, e.g. "gzip" }, cache: time in ms to cache for. paging: { limit: how many to return skip: how many to skip }, sort: {sort data} auth: { type: "Basic"|"Bearer"|"ApiKey", raw: raw authentication header data, sans meta data value: auth.raw, but base64 un-encoded. username: username of the authenticated user password: password of the authenticated user }, output: { code: The eventual HTTP response code e.g. 204 data: the data emitted from the result of this call }, httpRequest: HTTPRequest Object httpResponse: HTTPResponse object wsMessage: The message received from the Websocket Client wsClient: WebSocket Client Object } */