UNPKG

lambda-api

Version:

Lightweight web framework for your serverless applications

555 lines (473 loc) 16.4 kB
'use strict'; /** * Lightweight web framework for your serverless applications * @author Jeremy Daly <jeremy@jeremydaly.com> * @license MIT */ const REQUEST = require('./lib/request'); const RESPONSE = require('./lib/response'); const UTILS = require('./lib/utils'); const LOGGER = require('./lib/logger'); const S3 = () => require('./lib/s3-service'); const { ConfigurationError, ApiError } = require('./lib/errors'); const prettyPrint = require('./lib/prettyPrint'); class API { constructor(props) { this._version = props && props.version ? props.version : 'v1'; this._base = props && props.base && typeof props.base === 'string' ? props.base.trim() : ''; this._callbackName = props && props.callback ? props.callback.trim() : 'callback'; this._mimeTypes = props && props.mimeTypes && typeof props.mimeTypes === 'object' ? props.mimeTypes : {}; this._serializer = props && props.serializer && typeof props.serializer === 'function' ? props.serializer : JSON.stringify; this._errorHeaderWhitelist = props && Array.isArray(props.errorHeaderWhitelist) ? props.errorHeaderWhitelist.map((header) => header.toLowerCase()) : []; this._isBase64 = props && typeof props.isBase64 === 'boolean' ? props.isBase64 : false; this._headers = props && props.headers && typeof props.headers === 'object' ? props.headers : {}; this._compression = props && (typeof props.compression === 'boolean' || Array.isArray(props.compression)) ? props.compression : false; this._s3Config = props && props.s3Config; // Set S3 Client if (this._s3Config) S3().setConfig(this._s3Config); this._sampleCounts = {}; this._requestCount = 0; this._initTime = Date.now(); this._logLevels = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60, }; this._logger = LOGGER.config(props && props.logger, this._logLevels); // Prefix stack w/ base this._prefix = this.parseRoute(this._base); this._routes = {}; // Init callback this._cb; // Error middleware stack this._errors = []; // Store app packages and namespaces this._app = {}; // Executed after the callback this._finally = () => {}; // Global error status (used for response parsing errors) this._errorStatus = 500; this._methods = [ 'get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'any', ]; // Convenience methods for METHOD this._methods.forEach((m) => { this[m] = (...a) => this.METHOD(m.toUpperCase(), ...a); }); } // METHOD: Adds method, middleware, and handlers to routes METHOD(method, ...args) { // Extract path if provided, otherwise default to global wildcard let path = typeof args[0] === 'string' ? args.shift() : '/*'; // Extract the execution stack let stack = args.map((fn, i) => { if ( typeof fn === 'function' && (fn.length === 3 || i === args.length - 1) ) return fn; throw new ConfigurationError( 'Route-based middleware must have 3 parameters' ); }); if (stack.length === 0) throw new ConfigurationError( `No handler or middleware specified for ${method} method on ${path} route.` ); // Ensure methods is an array and upper case let methods = (Array.isArray(method) ? method : method.split(',')).map( (x) => (typeof x === 'string' ? x.trim().toUpperCase() : null) ); // Parse the path let parsedPath = this.parseRoute(path); // Split the route and clean it up let route = this._prefix.concat(parsedPath); // For root path support if (route.length === 0) { route.push(''); } // Keep track of path variables let pathVars = {}; // Make a local copy of routes let routes = this._routes; // Create a local stack for inheritance let _stack = { '*': [], m: [] }; // Loop through the path levels for (let i = 0; i < route.length; i++) { // Flag as end of the path let end = i === route.length - 1; // If this is a parameter variable if (/^:(.*)$/.test(route[i])) { // Assign it to the pathVars (trim off the : at the beginning) pathVars[i] = [route[i].substr(1)]; // Set the route to __VAR__ route[i] = '__VAR__'; } // end if variable // Create routes and add path if they don't exist if (!routes['ROUTES']) { routes['ROUTES'] = {}; } if (!routes['ROUTES'][route[i]]) { routes['ROUTES'][route[i]] = {}; } // Loop through methods for the route methods.forEach((_method) => { // Method must be a string if (typeof _method === 'string') { // Check for wild card at this level if (routes['ROUTES']['*']) { if ( routes['ROUTES']['*']['MIDDLEWARE'] && (route[i] !== '*' || _method !== '__MW__') ) { _stack['*'][method] = routes['ROUTES']['*']['MIDDLEWARE'].stack; } if ( routes['ROUTES']['*']['METHODS'] && routes['ROUTES']['*']['METHODS'][method] ) { _stack['m'][method] = routes['ROUTES']['*']['METHODS'][method].stack; } } // end if wild card // If this is the end of the path if (end) { // Check for matching middleware if ( route[i] !== '*' && routes['ROUTES'][route[i]] && routes['ROUTES'][route[i]]['MIDDLEWARE'] ) { _stack['m'][method] = routes['ROUTES'][route[i]]['MIDDLEWARE'].stack; } // end if // Generate the route/method meta data let meta = { vars: pathVars, stack: _stack['m'][method] ? _stack['m'][method].concat(stack) : _stack['*'][method] ? _stack['*'][method].concat(stack) : stack, // inherited: _stack[method] ? _stack[method] : [], route: '/' + parsedPath.join('/'), path: '/' + this._prefix.concat(parsedPath).join('/'), }; // If mounting middleware if (method === '__MW__') { // Merge stacks if middleware exists if (routes['ROUTES'][route[i]]['MIDDLEWARE']) { meta.stack = routes['ROUTES'][route[i]]['MIDDLEWARE'].stack.concat(stack); meta.vars = UTILS.mergeObjects( routes['ROUTES'][route[i]]['MIDDLEWARE'].vars, pathVars ); } // Add/update middleware routes['ROUTES'][route[i]]['MIDDLEWARE'] = meta; // Apply middleware to all child middlware routes // if (route[i] === "*") { // // console.log("APPLY NESTED MIDDLEWARE"); // // console.log(JSON.stringify(routes["ROUTES"], null, 2)); // Object.keys(routes["ROUTES"]).forEach((nestedRoute) => { // if (nestedRoute != "*") { // console.log(nestedRoute); // } // }); // } } else { // Create the methods section if it doesn't exist if (!routes['ROUTES'][route[i]]['METHODS']) routes['ROUTES'][route[i]]['METHODS'] = {}; // Merge stacks if method already exists for this route if (routes['ROUTES'][route[i]]['METHODS'][_method]) { meta.stack = routes['ROUTES'][route[i]]['METHODS'][_method].stack.concat( stack ); meta.vars = UTILS.mergeObjects( routes['ROUTES'][route[i]]['METHODS'][_method].vars, pathVars ); } // Add method and meta data routes['ROUTES'][route[i]]['METHODS'][_method] = meta; } // end else // console.log('STACK:',meta); // If there's a wild card that's not at the end } else if (route[i] === '*') { throw new ConfigurationError( 'Wildcards can only be at the end of a route definition' ); } // end if end of path } // end if method is string }); // end methods loop // Update the current routes pointer routes = routes['ROUTES'][route[i]]; } // end path traversal loop // console.log(JSON.stringify(this._routes,null,2)); } // end main METHOD function // RUN: This runs the routes async run(event, context, cb) { // Set the event, context and callback this._event = event || {}; this._context = this.context = typeof context === 'object' ? context : {}; this._cb = cb ? cb : undefined; // Initalize request and response objects let request = new REQUEST(this); let response = new RESPONSE(this, request); try { // Parse the request await request.parseRequest(); // Loop through the execution stack for (const fn of request._stack) { // Only run if in processing state if (response._state !== 'processing') break; // eslint-disable-next-line await new Promise(async (r) => { try { let rtn = await fn(request, response, () => { r(); }); if (rtn) response.send(rtn); if (response._state === 'done') r(); // if state is done, resolve promise } catch (e) { await this.catchErrors(e, response); r(); // resolve the promise } }); } // end for } catch (e) { // console.log(e); await this.catchErrors(e, response); } // Return the final response return response._response; } // end run function // Catch all async/sync errors async catchErrors(e, response, code, detail) { response._isBase64 = this._isBase64; const strippedHeaders = Object.entries(response._headers).reduce( (acc, [headerName, value]) => { if (!this._errorHeaderWhitelist.includes(headerName.toLowerCase())) { return acc; } return Object.assign(acc, { [headerName]: value }); }, {} ); response._headers = Object.assign(strippedHeaders, this._headers); let message; response.status(code ? code : this._errorStatus); let info = { detail, statusCode: response._statusCode, coldStart: response._request.coldStart, stack: (this._logger.stack && e.stack) || undefined, }; const isApiError = e instanceof ApiError; if (e instanceof Error && !isApiError) { message = e.message; if (this._logger.errorLogging) { this.log.fatal(message, info); } } else { message = e instanceof Error ? e.message : e; if (this._logger.errorLogging) { this.log.error(message, info); } } // If first time through, process error middleware if (response._state === 'processing') { // Flag error state (this will avoid infinite error loops) response._state = 'error'; // Execute error middleware for (const err of this._errors) { if (response._state === 'done') break; // Promisify error middleware // TODO: using async within a promise is an antipattern, therefore we need to refactor this asap // eslint-disable-next-line no-async-promise-executor await new Promise(async (r) => { let rtn = await err(e, response._request, response, () => { r(); }); if (rtn) response.send(rtn); r(); }); } } // Throw standard error unless callback has already been executed if (response._state !== 'done') response.json({ error: message }); } // end catch // Custom callback async _callback(err, res, response) { // Set done status response._state = 'done'; // Execute finally await this._finally(response._request, response); // Output logs response._request._logs.forEach((log) => { this._logger.logger( JSON.stringify( this._logger.detail ? this._logger.format(log, response._request, response) : log ) ); }); // Generate access log if ( (this._logger.access || response._request._logs.length > 0) && this._logger.access !== 'never' ) { let access = Object.assign( this._logger.log( 'access', undefined, response._request, response._request.context ), { statusCode: res.statusCode, coldStart: response._request.coldStart, count: response._request.requestCount, } ); this._logger.logger( JSON.stringify(this._logger.format(access, response._request, response)) ); } // Reset global error code this._errorStatus = 500; // Execute the primary callback typeof this._cb === 'function' && this._cb(err, res); } // end _callback // Middleware handler use(...args) { // Extract routes let routes = typeof args[0] === 'string' ? Array.of(args.shift()) : Array.isArray(args[0]) ? args.shift() : ['/*']; // Init middleware stack let middleware = []; // Add func args as middleware for (let arg in args) { if (typeof args[arg] === 'function') { if (args[arg].length === 3) { middleware.push(args[arg]); } else if (args[arg].length === 4) { this._errors.push(args[arg]); } else { throw new ConfigurationError( 'Middleware must have 3 or 4 parameters' ); } } } // Add middleware for all methods if (middleware.length > 0) { routes.forEach((route) => { this.METHOD('__MW__', route, ...middleware); }); } } // end use // Finally handler finally(fn) { this._finally = fn; } //-------------------------------------------------------------------------// // UTILITY FUNCTIONS //-------------------------------------------------------------------------// parseRoute(path) { return path .trim() .replace(/^\/(.*?)(\/)*$/, '$1') .split('/') .filter((x) => x.trim() !== ''); } // Load app packages app(packages) { // Check for supplied packages if (typeof packages === 'object') { // Loop through and set package namespaces for (let namespace in packages) { try { this._app[namespace] = packages[namespace]; } catch (e) { console.error(e.message); // eslint-disable-line no-console } } } else if (arguments.length === 2 && typeof packages === 'string') { this._app[packages] = arguments[1]; } // end if // Return a reference return this._app; } // Register routes with options register(fn, opts) { let options = typeof opts === 'object' ? opts : {}; // Extract Prefix let prefix = options.prefix && options.prefix.toString().trim() !== '' ? this.parseRoute(options.prefix) : []; // Concat to existing prefix this._prefix = this._prefix.concat(prefix); // Execute the routing function fn(this, options); // Remove the last prefix (if a prefix exists) if (prefix.length > 0) { this._prefix = this._prefix.slice(0, -prefix.length); } } // end register // prettyPrint debugger routes(format) { // Parse the routes let routes = UTILS.extractRoutes(this._routes); if (format) { console.log(prettyPrint(routes)); // eslint-disable-line no-console } else { return routes; } } } // end API class // Export the API class as a new instance module.exports = (opts) => new API(opts); // Add createAPI as default export (to match index.d.ts) module.exports.default = module.exports;