lambda-api
Version:
Lightweight web framework for your serverless applications
613 lines (523 loc) • 17.2 kB
JavaScript
'use strict';
/**
* Lightweight web framework for your serverless applications
* @author Jeremy Daly <jeremy@jeremydaly.com>
* @license MIT
*/
const UTILS = require('./utils.js');
const fs = require('fs'); // Require Node.js file system
const path = require('path'); // Require Node.js path
const compression = require('./compression'); // Require compression lib
const { ResponseError, FileError, ApiError } = require('./errors'); // Require custom errors
// Lazy load AWS S3 service
const S3 = () => require('./s3-service');
class RESPONSE {
// Create the constructor function.
constructor(app, request) {
// Add a reference to the main app
app._response = this;
// Create a reference to the app
this.app = app;
// Create a reference to the request
this._request = request;
// Create a reference to the JSON serializer
this._serializer = app._serializer;
// Set the default state to processing
this._state = 'processing';
// Default statusCode to 200
this._statusCode = 200;
// Default the header
this._headers = Object.assign(
{
// Set the Content-Type by default
'content-type': ['application/json'], //charset=UTF-8
},
app._headers
);
// base64 encoding flag
this._isBase64 = app._isBase64;
// compression flag
this._compression = app._compression;
// Default callback function
this._callback = 'callback';
// Default Etag support
this._etag = false;
// Default response object
this._response = {};
}
// Sets the statusCode
status(code) {
this._statusCode = code;
return this;
}
// Adds a header field
header(key, value, append) {
let _key = key.toLowerCase(); // store as lowercase
let _values = value ? (Array.isArray(value) ? value : [value]) : [''];
this._headers[_key] = append
? this.hasHeader(_key)
? this._headers[_key].concat(_values)
: _values
: _values;
return this;
}
// Gets a header field
getHeader(key, asArr) {
if (!key)
return asArr
? this._headers
: Object.keys(this._headers).reduce(
(headers, key) =>
Object.assign(headers, { [key]: this._headers[key].toString() }),
{}
); // return all headers
return asArr
? this._headers[key.toLowerCase()]
: this._headers[key.toLowerCase()]
? this._headers[key.toLowerCase()].toString()
: undefined;
}
// Issue #130
setHeader(...args) {
return this.header(...args);
}
getHeaders() {
return this._headers;
}
// Removes a header field
removeHeader(key) {
delete this._headers[key.toLowerCase()];
return this;
}
// Returns boolean if header exists
hasHeader(key) {
return this.getHeader(key ? key : '') !== undefined;
}
// Convenience method for JSON
json(body) {
this.header('Content-Type', 'application/json').send(
this._serializer(body)
);
}
// Convenience method for JSONP
jsonp(body) {
// Check the querystring for callback or cb
let query = this.app._event.queryStringParameters || {};
let cb = query[this.app._callbackName];
this.header('Content-Type', 'application/json').send(
(cb ? cb.replace(' ', '_') : 'callback') +
'(' +
this._serializer(body) +
')'
);
}
// Convenience method for HTML
html(body) {
this.header('Content-Type', 'text/html').send(body);
}
// Convenience method for setting Location header
location(path) {
this.header('Location', UTILS.encodeUrl(path));
return this;
}
// Convenience method for Redirect
async redirect(path) {
let statusCode = 302; // default
try {
// If status code is provided
if (arguments.length === 2) {
if ([300, 301, 302, 303, 307, 308].includes(arguments[0])) {
statusCode = arguments[0];
path = arguments[1];
} else {
throw new ResponseError(
arguments[0] + ' is an invalid redirect status code',
arguments[0]
);
}
}
// Auto convert S3 paths to signed URLs
if (UTILS.isS3(path)) path = await this.getLink(path);
let url = UTILS.escapeHtml(path);
this.location(path)
.status(statusCode)
.html(
`<p>${statusCode} Redirecting to <a href="${url}">${url}</a></p>`
);
} catch (e) {
this.error(e);
}
} // end redirect
// Convenience method for retrieving a signed link to an S3 bucket object
async getLink(path, expires, callback) {
let params = UTILS.parseS3(path);
// Default Expires
params.Expires = !isNaN(expires) ? parseInt(expires) : 900;
// Default callback
let fn =
typeof expires === 'function'
? expires
: typeof callback === 'function'
? callback
: (e) => {
if (e) this.error(e);
};
// getSignedUrl doesn't support .promise()
return await new Promise((r) =>
S3().getSignedUrl('getObject', params, async (e, url) => {
if (e) {
// Execute callback with caught error
await fn(e);
this.error(e); // Throw error if not done in callback
}
r(url); // return the url
})
);
} // end getLink
// Convenience method for setting cookies
// see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
cookie(name, value, opts = {}) {
// Set the name and value of the cookie
let cookieString =
(typeof name !== 'string' ? name.toString() : name) +
'=' +
encodeURIComponent(UTILS.encodeBody(value));
// domain (String): Domain name for the cookie
cookieString += opts.domain ? '; Domain=' + opts.domain : '';
// expires (Date): Expiry date of the cookie, convert to GMT
cookieString +=
opts.expires && typeof opts.expires.toUTCString === 'function'
? '; Expires=' + opts.expires.toUTCString()
: '';
// httpOnly (Boolean): Flags the cookie to be accessible only by the web server
cookieString += opts.httpOnly && opts.httpOnly === true ? '; HttpOnly' : '';
// maxAge (Number) Set expiry time relative to the current time in milliseconds
cookieString +=
opts.maxAge && !isNaN(opts.maxAge)
? '; MaxAge=' +
((opts.maxAge / 1000) | 0) +
(!opts.expires
? '; Expires=' + new Date(Date.now() + opts.maxAge).toUTCString()
: '')
: '';
// path (String): Path for the cookie
cookieString += opts.path ? '; Path=' + opts.path : '; Path=/';
// secure (Boolean): Marks the cookie to be used with HTTPS only
cookieString += opts.secure && opts.secure === true ? '; Secure' : '';
// sameSite (Boolean or String) Value of the "SameSite" Set-Cookie attribute
// see https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00#section-4.1.1.
cookieString +=
opts.sameSite !== undefined
? '; SameSite=' +
(opts.sameSite === true
? 'Strict'
: opts.sameSite === false
? 'Lax'
: opts.sameSite)
: '';
this.header('Set-Cookie', cookieString, true);
return this;
}
// Convenience method for clearing cookies
clearCookie(name, opts = {}) {
let options = Object.assign(opts, { expires: new Date(1), maxAge: -1000 });
return this.cookie(name, '', options);
}
// Set content-disposition header and content type
attachment(filename) {
// Check for supplied filename/path
let name =
typeof filename === 'string' && filename.trim().length > 0
? path.parse(filename)
: undefined;
this.header(
'Content-Disposition',
'attachment' + (name ? '; filename="' + name.base + '"' : '')
);
// If name exits, attempt to set the type
if (name) {
this.type(name.ext);
}
return this;
}
// Convenience method combining attachment() and sendFile()
download(file, filename, options, callback) {
let name = filename;
let opts = typeof options === 'object' ? options : {};
let fn = typeof callback === 'function' ? callback : undefined;
// Add optional parameter support for callback
if (typeof filename === 'function') {
name = undefined;
fn = filename;
} else if (typeof options === 'function') {
fn = options;
}
// Add optional parameter support for options
if (typeof filename === 'object') {
name = undefined;
opts = filename;
}
// Add the Content-Disposition header
this.attachment(
name ? name : typeof file === 'string' ? path.basename(file) : null
);
// Send the file
this.sendFile(file, opts, fn);
}
// Convenience method for returning static files
async sendFile(file, options, callback) {
let buffer, modified;
let opts = typeof options === 'object' ? options : {};
let fn = typeof callback === 'function' ? callback : () => {};
// Add optional parameter support
if (typeof options === 'function') {
fn = options;
}
// Begin a try-catch block for callback errors
try {
// Create buffer based on input
if (typeof file === 'string') {
let filepath = file.trim();
// If an S3 file identifier
if (/^s3:\/\//i.test(filepath)) {
let params = UTILS.parseS3(filepath);
// Attempt to get the object from S3
let data = await S3().getObject(params).promise();
// Set results, type and header
buffer = data.Body;
modified = data.LastModified;
this.type(data.ContentType);
this.header('ETag', data.ETag);
// else try and load the file locally
} else {
buffer = fs.readFileSync((opts.root ? opts.root : '') + filepath);
modified =
opts.lastModified !== false
? fs.statSync((opts.root ? opts.root : '') + filepath).mtime
: undefined;
this.type(path.extname(filepath));
}
// If the input is a buffer, pass through
} else if (Buffer.isBuffer(file)) {
buffer = file;
} else {
throw new FileError('Invalid file', { path: file });
}
// Add headers from options
if (typeof opts.headers === 'object') {
Object.keys(opts.headers).map((header) => {
this.header(header, opts.headers[header]);
});
}
// Add cache-control headers
if (opts.cacheControl !== false) {
if (opts.cacheControl !== true && opts.cacheControl !== undefined) {
this.cache(opts.cacheControl);
} else {
this.cache(!isNaN(opts.maxAge) ? opts.maxAge : 0, opts.private);
}
}
// Add last-modified headers
if (opts.lastModified !== false) {
this.modified(opts.lastModified ? opts.lastModified : modified);
}
// Execute callback
await fn();
// Set base64 encoding flag
this._isBase64 = true;
// Convert buffer to base64 string
this.send(buffer.toString('base64'));
} catch (e) {
// TODO: Add second catch?
// Execute callback with caught error
await fn(e);
// If missing file
if (e.code === 'ENOENT') {
this.error(new FileError('No such file', e));
} else {
this.error(e); // Throw error if not done in callback
}
}
} // end sendFile
// Convenience method for setting type
type(type) {
let mimeType = UTILS.mimeLookup(type, this.app._mimeTypes);
if (mimeType) {
this.header('Content-Type', mimeType);
}
return this;
}
// Convenience method for sending status codes
sendStatus(status) {
this.status(status).send(UTILS.statusLookup(status));
}
// Convenience method for setting CORS headers
cors(options) {
const opts = typeof options === 'object' ? options : {};
// Check for existing headers
let acao = this.getHeader('Access-Control-Allow-Origin');
let acam = this.getHeader('Access-Control-Allow-Methods');
let acah = this.getHeader('Access-Control-Allow-Headers');
// Default CORS headers
this.header(
'Access-Control-Allow-Origin',
opts.origin ? opts.origin : acao ? acao : '*'
);
this.header(
'Access-Control-Allow-Methods',
opts.methods
? opts.methods
: acam
? acam
: 'GET, PUT, POST, DELETE, OPTIONS'
);
this.header(
'Access-Control-Allow-Headers',
opts.headers
? opts.headers
: acah
? acah
: 'Content-Type, Authorization, Content-Length, X-Requested-With'
);
// Optional CORS headers
if (opts.maxAge && !isNaN(opts.maxAge))
this.header(
'Access-Control-Max-Age',
((opts.maxAge / 1000) | 0).toString()
);
if (opts.credentials)
this.header(
'Access-Control-Allow-Credentials',
opts.credentials.toString()
);
if (opts.exposeHeaders)
this.header('Access-Control-Expose-Headers', opts.exposeHeaders);
return this;
}
// Enable/Disable Etag
etag(enable) {
this._etag = enable === true ? true : false;
return this;
}
// Add cache-control headers
cache(maxAge, isPrivate = false) {
// if custom string value
if (maxAge !== true && maxAge !== undefined && typeof maxAge === 'string') {
this.header('Cache-Control', maxAge);
} else if (maxAge === false) {
this.header('Cache-Control', 'no-cache, no-store, must-revalidate');
} else {
maxAge = maxAge && !isNaN(maxAge) ? (maxAge / 1000) | 0 : 0;
this.header(
'Cache-Control',
(isPrivate === true ? 'private, ' : '') + 'max-age=' + maxAge
);
this.header('Expires', new Date(Date.now() + maxAge).toUTCString());
}
return this;
}
// Add last-modified headers
modified(date) {
if (date !== false) {
let lastModified =
date && typeof date.toUTCString === 'function'
? date
: date && Date.parse(date)
? new Date(date)
: new Date();
this.header('Last-Modified', lastModified.toUTCString());
}
return this;
}
// Sends the request to the main callback
send(body) {
// Generate Etag
if (
this._etag && // if etag support enabled
['GET', 'HEAD'].includes(this._request.method) &&
!this.hasHeader('etag') &&
this._statusCode === 200
) {
this.header('etag', '"' + UTILS.generateEtag(body) + '"');
}
// Check for matching Etag
if (
this._request.headers['if-none-match'] &&
this._request.headers['if-none-match'] === this.getHeader('etag')
) {
this.status(304);
body = '';
}
let headers = {};
let cookies = {};
if (this._request.payloadVersion === '2.0') {
if (this._headers['set-cookie']) {
cookies = { cookies: this._headers['set-cookie'] };
delete this._headers['set-cookie'];
}
}
if (this._request._multiValueSupport) {
headers = { multiValueHeaders: this._headers };
} else {
headers = { headers: UTILS.stringifyHeaders(this._headers) };
}
// Create the response
this._response = Object.assign(
{},
headers,
cookies,
{
statusCode: this._statusCode,
body:
this._request.method === 'HEAD'
? ''
: UTILS.encodeBody(body, this._serializer),
isBase64Encoded: this._isBase64,
},
this._request.interface === 'alb'
? {
statusDescription: `${this._statusCode} ${UTILS.statusLookup(
this._statusCode
)}`,
}
: {}
);
// Compress the body
if (this._compression && this._response.body) {
const { data, contentEncoding } = compression.compress(
this._response.body,
this._request.headers,
Array.isArray(this._compression) ? this._compression : null
);
if (contentEncoding) {
Object.assign(this._response, {
body: data.toString('base64'),
isBase64Encoded: true,
});
if (this._response.multiValueHeaders) {
this._response.multiValueHeaders['content-encoding'] = [
contentEncoding,
];
} else {
this._response.headers['content-encoding'] = contentEncoding;
}
}
}
// Trigger the callback function
this.app._callback(null, this._response, this);
} // end send
// Trigger API error
error(code, e, detail) {
const message = typeof code !== 'number' ? code : e;
const statusCode = typeof code === 'number' ? code : undefined;
const errorDetail =
typeof code !== 'number' && e !== undefined ? e : detail;
const errorToSend =
typeof message === 'string'
? new ApiError(message, statusCode, errorDetail)
: message;
this.app.catchErrors(errorToSend, this, statusCode, errorDetail);
} // end error
} // end Response class
// Export the response object
module.exports = RESPONSE;