UNPKG

celeritas

Version:

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

586 lines (518 loc) 18.9 kB
'use strict'; const https = require('https'); const http = require('http'); const fs = require('fs'); const path = require('path'); const winston = require('winston'); const uuid = require('uuid'); const crypto = require("crypto"); const cluster = require('cluster'); const os = require('os'); const newrelic = (fs.existsSync("./newrelic.js")) ? require('newrelic') : false; const Call = require('./lib/call.js'); const CallUtilities = require("./lib/utilities.js"); CallUtilities.validate = require("./lib/validate.js"); const CeleritasWS = require("./lib/websocketserver.js"); const Router = require("./lib/router.js"); /* @Celeritas: Creates an instance of class Celeritas; Celeritas is an application container which runs a standard HTTPS/WSS API service. Celeritas should be a singleton. arguments: { options: { api: { route: Default route is "api/v1/", this defines the path to access APIs exposed by your application, e.g. "GET http://api.example.com/api/v1/API" directory: The directory the application searches for to find API methods. e.g. "./apis", timeout: Time in MS before a request to the API times out, metaData: TRUE|FALSE, if true, api responses will include meta data about the API result. Otherwise, the response will only contain the contents of the result array, castResultsAsArray: TRUE|FALSE, if true, output.data.result will always be an array. Otherwise, it is the raw response. }, autoload: This autoloads modules from directories in your app folder, and assigns them to the key passed. [ path: directory ], server: { protocol: "http" or "https" port: 8443; the desired TCP port the api should be available on. ws: true|false; if http pass-through via websocket is supported. }, cluster: { enabled: true|false, determines if the app will run as a cluster of nodes rather than a single 1-core app, forks: the number of child processes that will run the app. }, debug: true|false, determines if debug mode is on for not. accessControl: default access control headers for HTTP requests that the API serves. { allowOrigin: "*", allowHeaders: 'Content-Type, Authorization, Accept', exposeHeaders: 'Content-Type, Cache-Control, Authorization', allowMethods: "GET, POST, PUT, DELETE, OPTIONS" }, //Any other options you define will also be loaded into the Celeritas instance. } } */ class Celeritas { constructor (options) { var version = require('./package.json').version; var numCPUs = os.cpus().length; var defaults = { id: uuid(), name: "celeritas@" + version, debug: false, newrelic: newrelic, validation: "native", logMode: "VERBOSE", //QUIET or VERBOSE api: { route: "v1/", directory: "./routes", timeout: 60 * 1000, metaData: true, castResultsAsArray: false, alphabetizeResponse: true, completeHandler: null }, server: { protocol: "https", port: process.env.PORT || 8080, ws: true, enforceHttps: false }, cluster: { enabled: (numCPUs !== 1), forks: (numCPUs -1) || 1 }, accessControl: { allowOrigin: "*", allowMethods: "GET, POST, PUT, DELETE, OPTIONS", allowHeaders: 'Content-Type, Cache-Control, Authorization, Accept, Accept-Encoding, X-Return-Input', exposeHeaders: 'Content-Type, Cache-Control, Authorization, X-Request-Id, X-Powered-By' }, blacklist: { uris: fs.existsSync("./blacklist.txt") === true ? fs.readFileSync('./blacklist.txt').toString().split("\r\n") : [] }, preload: () => {} } var autoload = options.autoload; delete options.autoload; for (var i in defaults) { if (typeof defaults[i] == "object" || typeof options[i] == "object") Object.assign(defaults[i], options[i] || {}); else defaults[i] = options[i]; } for (var i in options) { if (typeof defaults[i] == "undefined") defaults[i] = options[i]; } Object.assign(this, defaults); for (var i in autoload) this._autoload(autoload[i], i); this._version = version; if (!fs.existsSync("./logs")) fs.mkdirSync("./logs", parseInt('0744', 8)); this._log = new (winston.Logger)({ transports: [ new (winston.transports.Console)({ timestamp: true, colorize: true }), new (winston.transports.File)({ name: "Info-Log", timestamp: true, filename: "./logs/infos.log", level: 'info' }), new (winston.transports.File)({ name: "Warning-Log", timestamp: true, filename: "./logs/warnings.log", level: 'warning' }), new (winston.transports.File)({ name: "Error-Log", timestamp: true, filename: "./logs/errors.log", level: 'error' }) ] }); this._calls = {}; this._createAPIRoutes(this.api.directory); this._startup(); } async _startup () { if (typeof this.preload == "function") await this.preload(this); //this function will start the http/https/ws listening. let init = (emit = true) => { //Start up either an HTTP server or HTTPS server. var server = this["_" + this.server.protocol](); //Start up a Websocket server using the http(s) server created. if (this.server.ws !== false) this._ws(server); if (emit) this._welcomeMessage(); } //If clustering is enabled, run as a cluster, with the HTTP/WS services running on worker processes. Otherwise, run them as usual. if (this.cluster.enabled === true) { if (cluster.isMaster) { //if cluster is enabled, the master thread does not listen on any ports, and is reserved for managing the forked processes. this._log.info(`${this.name} is running as a cluster. Master process '${process.pid}' has started.`); for (let i = 0; i < this.cluster.forks; i++) { //Slightly delay forking the cluster so logging doesn't get all fucked from async stream writes. setTimeout(cluster.fork, i * 150); } cluster.on('exit', (worker, code, signal) => { this._log.info(`${this.name} cluster fork '${worker.process.pid}' has died.`); cluster.fork(); }); this._welcomeMessage(); } else { this._log.info(`${this.name} cluster fork '${process.pid}' has started.`); init(false); } } else init(true); //For some extra debugging of promises and exceptions. if (this.debug) { process.on("unhandledRejection", (err) => { this._log.error(err); console.error(err); if (this.newrelic) this.newrelic.noticeError(err); process.exit(); }); process.on('uncaughtException', (err) => { this._log.error(err); console.log(err); if (this.newrelic) this.newrelic.noticeError(err); process.exit(); }) } } _welcomeMessage () { //var port = (typeof this.server.redirect !== "undefined") ? this.server.https_port : this.server.http_port; var host = "http://localhost:" + this.server.port + "/";// + this.api.route; var border = ""; for (var i = 0; i < host.length; i++) border += "="; var space = ""; for (var i = 0; i < host.length; i++) space += " "; console.log("\n Started " + this.name + "! Your API is available at this URL: -\n"); console.log(" O==" + border + "==O"); console.log(" | " + space + " |"); console.log(" | " + host + " |"); console.log(" | " + space + " |"); console.log(" O==" + border + "==O"); console.log(`\n Machine hostname: ${os.hostname()}: CPU#: ${os.cpus().length}`) //console.log("\n API Routes: - \n"); //console.log(this.routes); console.log("\n"); } //This method determines what the root directory of the application is, since Azure App Service runs node.js in a weird way. _appDir () { if (process.env.APP_DIR) return process.env.APP_DIR; var iisPaths = ["D:\\Program Files\\iisnode", "D:\\Program Files (x86)\\iisnode"]; var app_dir = path.dirname(require.main.filename); if (app_dir in iisPaths) return "D:\\home\\site\\wwwroot"; else return app_dir; } //This method autoloads files from a directory. The modules they export are assigned as properties of this[propertyname]. E.g. this.domains.users == module; _autoload (directory, propertyName) { this[propertyName] = this[propertyName] || {}; var iterate = (directory) => { if (fs.existsSync(directory)) { var files = fs.readdirSync(directory); for (var f = 0 ; f < files.length; f++) { if (files[f].indexOf("_") === -1) { if (fs.lstatSync(directory + "/" + files[f]).isDirectory()) { iterate(directory + "/" + files[f]) } else { var file = path.normalize(this._appDir() + "/" + directory.substring(1) + "/" + files[f]); this[propertyName][files[f].split(".")[0]] = require(file); var thing = this[propertyName][files[f].split(".")[0]]; try { thing(); } catch (err) { try { this[propertyName][files[f].split(".")[0]] = new thing(this); } catch (err) { if (err.message != "thing is not a constructor") throw err; } } } } } } } iterate(directory); } //This method accepts a directory to search for files within. Each file will be parsed for API routes. _createAPIRoutes (directory) { this.routes = this.routes || {}; var iterate = (directory) => { if (fs.existsSync(directory)) { var routes = fs.readdirSync(directory); for (var e = 0; e < routes.length; e++) { if (routes[e].indexOf("_") === -1) { if (fs.lstatSync(directory + "/" + routes[e]).isDirectory()) { iterate(directory + "/" + routes[e]); } else { var file = path.normalize(this._appDir() + "/" + directory.substring(1) + "/" + routes[e]); var apiRoutes = require(file).routes; for (var api in apiRoutes) this.routes[this.api.route + api] = apiRoutes[api]; } } } } else throw new Error("Cannot create API routes from directory '" + directory + "'; directory doesn't exist."); } iterate(directory); } //Sets up the HTTP server for serving HTTP requests. (HTTP protocol) _http () { this.server.protocol = "http"; this.server.http = http.createServer(async (req, res) => { let call = await this._request("http", req, res); this._defaultCompleteHandler(call); }).listen(this.server.port); return this.server.http; } //Sets up the HTTP server for serving HTTP requests. (HTTPS protocol), this method supports TLS. Creates HTTP instead of certificate/key not available. _https () { try { var credentials = {key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem')}; } catch (err) { return this._http(); } this.server.http = https.createServer(credentials, async (req, res) => { let call = await this._request("http", req, res); this._defaultCompleteHandler(call); }).listen(this.server.port); //redirect all HTTP traffic to HTTPS. //this.server.redirect = http.createServer('*', (req, res) => { // res.redirect('https://' + req.headers.host + req.url); //}).listen(this.server.http_port); return this.server.https; } //Sets up the WebSocket server for sending and receiving websocket messages. _ws (httpServer) { this.server.ws = new CeleritasWS(this, httpServer); return this.server.ws; } //This method handles incoming requests from either HTTP or WebSocket, wraps it all up as a class Call, which processes the API request. /* arguments { type: "http" or "ws", indicates if the message came from an HTTP request or a Websocket message. request: if type=="http", it is the Http Request object. If type=="ws", it is the websocket JSON message. response: If type=="http", it is the response object. If type=="ws", it is the websocket client. } */ async _request (type, request, response) { try { switch (type) { case "http": var args = {type: type, request: request, response: response, util: CallUtilities}; break; case "ws": var args = {type: type, message: request, client: response, util: CallUtilities}; break; } var trackInNewrelic = true; try { var bannedEndings = [ '.php', '.aspx', '.ico', '.html', '.js', '.cfg', 'phpmyadmin', 'dbadmin' ]; if (type != "ws") { var route = request.url.split("?").shift().substring(1).toLowerCase(); for (let f of bannedEndings) { if (route.endsWith(f) == true) trackInNewrelic = false; } } } catch (err) { console.warn(err); } if (this.newrelic && trackInNewrelic === true) { return new Promise((resolve, reject) => { this.newrelic.startWebTransaction("", async () => { let transaction = this.newrelic.getTransaction(); let call = await new Call(this, args) this.newrelic.endTransaction(transaction); return resolve(call); }) }); } else { let call = await new Call(this, args); return call; } } catch (err) { console.warn(err); } } //wehn a call completes, this will run by default. This occurs after the new relic web transaction has completed and the api response has been emitted. async _defaultCompleteHandler (call) { if (typeof this.api.completeHandler == "function") { //if we're using new relic, track this as a background job. if (this.newrelic) { return new Promise((resolve, reject) => { this.newrelic.startBackgroundTransaction("", async () => { let transaction = this.newrelic.getTransaction(); try { var job = await this.api.completeHandler(call); } catch (err) { this.newrelic.noticeError(err); } this.newrelic.endTransaction(transaction); return job; }) }); } else { try { return await this.api.completeHandler(call); } catch (err) { console.error(err); } } } } async trace (name, handler) { return new Promise((resolve, reject) => { try { if (this.newrelic) { this.newrelic.startSegment(name, false, async () => { return resolve(await handler()); }); } else return resolve(handler()); } catch (err) { return reject(err); } }) } //Cache an API call response, to be used by others requesting this data. cache (call) { var t = Date.now(); var key = this._cacheKey(call); var toCache = { from: t, til: t + (call.api.cache*1000), output: call.output }; if (this.redis && this.redis.constructor.name == "RedisClient") { this.redis.set(key, JSON.stringify(toCache)); this.redis.expireAsync(key, call.api.cache*1000); } else { this._cache = this._cache || {}; this._cache[key] = toCache; //Once per second, check if cached data should be cleared. if (typeof this._cacheTIL == "undefined") { this._cacheTIL = setInterval(() => { for (var i in this._cache) { if (this._cache[i].til < Date.now()) delete this._cache[i]; } }, 1000); } } } //Retrievs a cached API response. retrieve (call, maxAge) { this._cache = this._cache || {}; var adjustCachedResponse = function (call, key, cachedData) { cachedData.output.data.meta.requestId = uuid(); cachedData.output.data.meta.time = { emittedAtUtc: new Date().toISOString(), receivedAtUtc: call.timestamp, totalTimeInMS: (Date.now() - Date.parse(call.timestamp)) }; //cachedData.output.data.meta.cacheKey = key; return cachedData; } var key = this._cacheKey(call); if (this.redis && this.redis.constructor.name == "RedisClient") { return new Promise((resolve, reject) => { this.redis.get(key, (err, cachedData) => { if (err || cachedData === null) return resolve(null); cachedData = JSON.parse(cachedData); cachedData = adjustCachedResponse(call, key, cachedData); call.output = cachedData.output; return resolve(cachedData); }); }); } else if (typeof this._cache[key] !== "undefined" && this._cache[key].til > Date.now()) { var cachedData = adjustCachedResponse(call, key, this._cache[key]); call.output = cachedData.output; return Promise.resolve(cachedData); } return Promise.resolve(null); } //Returns a key which uniquely identifies a cache-able call, used by the "cache" and "retrieve" methods. _cacheKey (call) { var key = JSON.stringify({ method: call.method, route: call.route, get: call.get, post: call.post, auth: crypto.createHash('sha512').update(JSON.stringify(call.auth)).digest('utf-8') }); return new Buffer(key).toString("base64"); } //This is an asynchronous method which returns a promise. This method is called to authenticate the user, and either allow the request to continue, or reject it based on how the method proceeds. //It takes in the request Call, and should resolve to a User object, which describes the currently authenticated user, or resolve to false if the user is not authenticated. //In this method, you should describe how the permissions you pass to _initializeAPIEndpoint affects who can access that API service. authenticate (call) { if (call.api.permissions === true) return Promise.resolve(true); else return Promise.resolve(false); } //the 'replay' function triggers when a websocket client connects, authenticates or subcribes. This function is intended as a way to "replay" events or messages that a websocket client might have missed. //for example, you could have DB "events". When a event occurs, it could be broadcast to clients subscribed to that type of event. //if any clients that are subscribed to that event "miss" that event (because the connection is closed), you could write this function to "replay" the event to the client. replay (client) { //client.user contains the details of the user object. //use this method to get cross-reference your events, and the client's subscriptions to determine if any events were missed. Then use 'client.event' to send the event through. //client.event returns a promise, if it resolves to true, then the message was delivered. Otherwise the message was not delivered. Update the event to reflect if the client got it. } broadcast (subscription, event) { if (!this.server.ws) throw new Error("Cannot broadcast events to websocket clients if there is no websocket server initialized."); return this.server.ws.broadcast(subscription, event); } //Celeritas runs natively as a cluster. Use this function if you want to specify code that should only be run by a single process. single (func) { if (cluster.isMaster && typeof func == "function") func(); } } module.exports = { Celeritas, Call, Router, Webhook: require("./lib/webhook.js"), Error: require('./lib/error.js'), validate: require("./lib/validate.js") };