celeritas
Version:
This is an API service framework which supports API requests over HTTP & WebSockets.
482 lines (420 loc) • 17.8 kB
JavaScript
;
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
}
*/