@chadkluck/cache-data
Version:
Cache data from an API endpoint or application process using AWS S3 and DynamoDb
567 lines (487 loc) • 18.2 kB
JavaScript
const RequestInfo = require('./RequestInfo.class');
const Timer = require('./Timer.class');
const DebugAndLog = require('./DebugAndLog.class');
/**
* Extends RequestInfo
* Can be used to create a custom ClientRequest object
*/
class ClientRequest extends RequestInfo {
static #validations = {
referrers: ['*'],
parameters: {}
};
static #authenticationIsRequired = false; // is it a public API (no authentication required) or authenticated API? (if both, set to false and use authorizations and roles)
static #unauthenticatedAuthorizations = (ClientRequest.#authenticationIsRequired) ? ['none'] : ['all']; // change from 'all' if there is a mix of public and authenticated access
/* we would need to add valid roles and authorizations as well as static */
/* What and who of the request */
#event = null;
#context = null;
#authorizations = JSON.parse(JSON.stringify(ClientRequest.#unauthenticatedAuthorizations));
#roles = [];
/* The request data */
#props = {};
/* Logging */
#timer = null;
#logs = {
pathLog: [],
queryLog: [],
apiKey: null
}
/**
* Initializes the request data based on the event. Also sets the
* validity of the request so it may be checked by .isValid()
* @param {object} event object from Lambda
*/
constructor(event, context) {
super(event);
this.#timer = new Timer("ClientRequest", true);
this.#event = event;
this.#context = context;
this.#authenticate();
const { resource, resourceArray, path, pathArray } = this.#extractResourceAndPath();
this.#props = {
method: this.#event.httpMethod,
path,
pathArray,
resource,
resourceArray,
pathParameters: {},
queryStringParameters: {},
headerParameters: {},
cookieParameters: {},
bodyPayload: this.#event?.body || null, // from body
client: {
isAuthenticated: this.isAuthenticated(),
isGuest: this.isGuest(),
authorizations: this.getAuthorizations(),
roles: this.getRoles()
},
deadline: (this.deadline() - 500),
calcMsToDeadline: this.calcMsToDeadline
};
this.#validate();
};
/**
* This is used to initialize the ClientRequest class for all requests.
* Add ClientRequest.init(options) to the Config.init process or at the
* top of the main index.js file outside of the handler.
* @param {Array<string>} options.validations.referrers An array of accepted referrers. String matching goes from right to left, so ['example.com'] will allow example.com and subdomain.example.com
* @param {object} options.validations.parameters An object containing functions for validating request parameters (path, querystring, headers, cookies, etc).
*/
static init(options) {
if (typeof options === 'object') {
if ('validations' in options) {
if ('referrers' in options.validations) {
ClientRequest.#validations.referrers = options.validations.referrers;
}
if ('parameters' in options.validations) {
ClientRequest.#validations.parameters = options.validations.parameters;
}
}
} else {
const errMsg = 'Application Configuration Error. Invalid options passed to ClientRequest.init(). Received:';
DebugAndLog.error(errMsg, options);
throw new Error(errMsg, options);
}
};
static getReferrerWhiteList() {
return ClientRequest.#validations.referrers;
};
static getParameterValidations() {
return ClientRequest.#validations.parameters;
};
/**
* Used in the constructor to set validity of the request
* This method may be customized to meet your validation needs
*/
#validate() {
let valid = false;
// add your additional validations here
valid = this.isAuthorizedReferrer() && this.#hasValidPathParameters() && this.#hasValidQueryStringParameters() && this.#hasValidHeaderParameters() && this.#hasValidCookieParameters();
// set the variable
super._isValid = valid;
};
#hasValidParameters(paramValidations, clientParameters) {
let rValue = {
isValid: true,
params: {}
}
if (clientParameters && paramValidations) {
// Use a for...of loop instead of forEach for better control flow
for (const [key, value] of Object.entries(clientParameters)) {
const paramKey = key.replace(/^\/|\/$/g, '');
const paramValue = value;
if (paramKey in paramValidations) {
const validationFunc = paramValidations[paramKey];
if (typeof validationFunc === 'function' && validationFunc(paramValue)) {
rValue.params[paramKey] = paramValue;
} else {
DebugAndLog.warn(`Invalid parameter: ${paramKey} = ${paramValue}`);
rValue.isValid = false;
rValue.params = {};
return rValue; // exit right away
}
}
}
}
return rValue;
}
#hasValidPathParameters() {
const { isValid, params } = this.#hasValidParameters(ClientRequest.getParameterValidations()?.pathParameters, this.#event?.pathParameters);
this.#props.pathParameters = params;
return isValid;
}
#hasValidQueryStringParameters() {
// lowercase all the this.#event.queryStringParameters keys
const qs = {};
for (const key in this.#event.queryStringParameters) {
qs[key.toLowerCase()] = this.#event.queryStringParameters[key];
}
const { isValid, params } = this.#hasValidParameters(ClientRequest.getParameterValidations()?.queryStringParameters, qs);
this.#props.queryStringParameters = params;
return isValid;
}
#hasValidHeaderParameters() {
// camel case all the this.#event.headers keys and remove hyphens
const headers = {};
for (const key in this.#event.headers) {
const camelCaseKey = key.toLowerCase().replace(/-([a-z])/g, (g) => g[1].toUpperCase());
headers[camelCaseKey] = this.#event.headers[key];
}
const { isValid, params } = this.#hasValidParameters(ClientRequest.getParameterValidations()?.headerParameters, headers);
this.#props.headerParameters = params;
return isValid;
}
#hasValidCookieParameters() {
const { isValid, params } = this.#hasValidParameters(ClientRequest.getParameterValidations()?.cookiearameters, this.#event?.cookie); // TODO
this.#props.cookieParameters = params;
return isValid;
}
/**
* Utility function for getPathArray and getResourceArray
* @param {array<string>} arr array to slice
* @param {number} n number of elements to return
* @returns {array<string>} array of elements
*/
#getArray(arr, n = 0) {
if (n === 0 || arr.length <= n || (n < 0 && arr.length <= (n*-1))) {
return arr;
} else if (n > 0) {
return arr.slice(0, n);
} else {
// Handle negative indices by counting from the end
return arr.slice(n);
}
};
#getElementAt(arr, n = 0) {
if (arr.length <= n || (n < 0 && arr.length <= (n*-1)-1)) return null;
if (n < 0) {
// Handle negative indices by counting from the end
return arr[arr.length + n];
} else {
return arr[n];
}
};
/**
* Get the first n path elements as a string.
* If n is 0, the whole path will be provided
* If n is a negative number, the last n elements will be provided
* The return value is a string with each element separated by a slash.
* @param {number} n number of elements to return.
* @returns {string} path elements
*/
getPath(n = 0) {
return this.getPathArray(n).join('/');
}
/**
* Get the first n path elements as an array.
* If n is 0, the whole path will be provided
* If n is a negative number, the last n elements will be provided
* The return value is an array of strings.
* @param {number} n number of elements to return.
* @returns {array<string>} path elements
*/
getPathArray(n = 0) {
return this.#getArray(this.#props.pathArray, n);
}
/**
* Get the path element at the specified index. If n is a negative number then return the nth element from the end.
* @param {number} n index of the resource to return
* @returns {string} path element
*/
getPathAt(n = 0) {
return this.#getElementAt(this.#props.pathArray, n);
}
/**
* Get the first n resource elements as a string.
* If n is 0, the whole resource will be provided
* If n is a negative number, the last n elements will be provided
* The return value is a string with each element separated by a slash.
* @param {number} n number of elements to return.
* @returns {string} resource elements
*/
getResource(n = 0) {
return this.getResourceArray(n).join('/');
}
/**
* Get the first n resource elements as an array.
* If n is 0, the whole resource will be provided
* If n is a negative number, the last n elements will be provided
* The return value is an array of strings.
* @param {number} n number of elements to return.
* @returns {array<string>} resource elements
*/
getResourceArray(n = 0) {
return this.#getArray(this.#props.resourceArray, n);
}
/**
* Get the resource element at the specified index. If n is a negative number then return the nth element from the end.
* @param {number} n index of the resource to return
* @returns {string} resource element
*/
getResourceAt(n = 0) {
return this.#getElementAt(this.#props.resourceArray, n);
}
/**
* Returns the path parameters received in the request.
* Path parameters are defined in the API's path definition and validated in the applications validation functions.
* @returns {object} path parameters
*/
getPathParameters() {
return this.#props.pathParameters;
};
/**
* Returns the query string parameters received in the request.
* Query string parameters are validated in the applications validation functions.
* @returns {object} query string parameters
*/
getQueryStringParameters() {
return this.#props.queryStringParameters;
};
/**
* Returns the header parameters received in the request.
* Only headers validated in the applications validation functions are returned.
* @returns {object} header parameters
*/
getHeaderParameters() {
return this.#props.headerParameters;
};
/**
* Returns the cookie parameters received in the request.
* Only cookies validated in the applications validation functions are returned.
* @returns {object} cookie parameters
*/
getCookieParameters() {
return this.#props.cookieParameters;
};
#authenticate() {
// add your authentication logic here
this.authenticated = false; // anonymous
};
isAuthenticated() {
return (ClientRequest.#authenticationIsRequired && this.authenticated);
};
isGuest() {
return (!ClientRequest.#authenticationIsRequired && !this.authenticated);
};
isAuthorizedToPerform(action="all") {
return ( this.getAuthorizations().includes(action) || this.getAuthorizations().includes('all'));
};
getRoles() {
if (this.isAuthenticated()) {
return this.#roles;
} else {
return ['guest'];
}
};
getAuthorizations() {
if (this.isAuthenticated()) {
return this.#authorizations;
} else {
return JSON.parse(JSON.stringify(ClientRequest.#unauthenticatedAuthorizations));
}
};
isAuthorizedReferrer() {
/* Check the array of valid referrers */
/* Check if the array includes a wildcard (*) OR if one of the whitelisted referrers matches the end of the referrer */
if (ClientRequest.requiresValidReferrer()) {
return true;
} else {
for (let i = 0; i < ClientRequest.#validations.referrers.length; i++) {
if (this.getClientReferer().endsWith(ClientRequest.#validations.referrers[i])) {
return true;
}
}
}
return false;
};
static requiresValidReferrer() {
return !ClientRequest.#validations.referrers.includes('*');
};
hasNoAuthorization() {
return (this.getAuthorizations().includes('none') || !this.isAuthorizedReferrer() );
};
getExecutionTime() {
return this.#timer.elapsed();
};
getFinalExecutionTime() {
return this.#timer.stop();
}
/**
* Get the _processed_ request properties. These are the properties that
* the ClientRequest object took from the event sent to Lambda, validated,
* supplemented, and makes available to controllers.
* @returns {{ method: string, path: string, pathArray: string[], resource: string, resourceArray[], pathParameters: {}, queryStringParameters: {}, headerParameters: {}, cookieParameters: {}, bodyPayload: string, client: {isAuthenticated: boolean, isGuest: boolean, authorizations: string[], roles: string[]}, deadline: number, calcMsToDeadline: number}
*/
getProps() {
return this.#props;
};
/**
* Add one or more path notations to the log.
* These are used for logging and monitoring. When a response is finalized the route
* is recorded in CloudWatch logs along with the status and other information.
* Do not send sensitive information in the path notation, use placeholders instead.
* For example, /user/{id}/profile instead of /user/123/profile
* However, /city/Chicago is acceptable because it is not a sensitive identifier.
* Only add meaningful parameters. You can abbreviate and rewrite long parameters.
* For example, /format/jpg can be coded as /f:jpg or /user/123/profile/privacy as /userProfile/privacy
* @param {string|Array<string>} path
*/
addPathLog(path = null) {
if (path === null) {
path = `${this.#props.method}:${this.#props.pathArray.join("/")}`;
}
if (typeof path === 'string') {
this.#logs.pathLog.push(path);
} else if (Array.isArray(path)) {
this.#logs.pathLog = this.#logs.pathLog.concat(path);
}
};
/**
* Add one or more query notations to the query log.
* These are used for logging and monitoring. When a response is finalized the
* parameters are recorded in CloudWatch logs along with the status and other
* information.
* Do not send sensitive information in the query notation, use placeholders instead.
* For example, user instead of user=123
* However, city=Chicago is acceptable because it is not a sensitive query.
* Only add meaningful parameters. You can abbreviate long parameters.
* For example, format=jpg can be coded as f:jpg
* @param {string|Array<string>} query
*/
addQueryLog(query) {
if (typeof query === 'string') {
this.#logs.queryLog.push(query);
} else if (Array.isArray(query)) {
this.#logs.queryLog = this.#logs.queryLog.concat(query);
}
};
/**
* Get the request log entries
* resource: http method and resource path with path parameter keys (no values)
* queryKeys: query string keys (no values)
* pathLog: custom route path with values (set by application using addPathLog())
* queryLog: custom query with or without values (set by addQueryLog())
* apiKey: last 6 characters of api key if present
* @returns {resource: string, queryKeys: string, routeLog: string, queryLog: string, apiKey: string}
*/
getRequestLog() {
return {
resource: `${this.#props.method}:${this.#props.resourceArray.join('/')}`,
// put queryString keys in alpha order and join with &
queryKeys: Object.keys(this.#props.queryStringParameters).sort().map(key => `${key}=${this.#props.queryStringParameters[key]}`).join('&'),
routeLog: this.#logs.pathLog.join('/'),
// put logs.params in alpha order and join with &
queryLog: this.#logs.queryLog.sort().join('&'),
// only show last 6 characters of this.apiKey
apiKey: (this.#logs.apiKey !== null) ? this.#logs.apiKey.substring(this.#logs.apiKey.length - 6) : null
};
};
timerStop() {
return this.#timer?.stop() || 0;
};
/**
*
* @returns {number} The remaining time before Lambda times out. 1000 if context is not set in ClientRequest object.
*/
getRemainingTimeInMillis() {
return this.getContext().getRemainingTimeInMillis() || 1000;
};
/**
* Get the number of milliseconds remaining and deduct the headroom given.
* Useful when you want to set a timeout on a function (such as an http request)
* that may take longer than our function has time for.
* @param {number} headroomInMillis number in milliseconds to deduct from Remaining Time
* @returns {number} greater than or equal to 0
*/
calcRemainingTimeInMillis(headroomInMillis = 0) {
let rt = this.getRemainingTimeInMillis() - headroomInMillis;
return (rt > 0 ? rt : 0);
};
/**
*
* @returns timestamp for when the remaining time is up
*/
deadline() {
return Date.now() + this.getRemainingTimeInMillis();
};
/**
*
* @returns Milliseconds to Deadline
*/
calcMsToDeadline(deadline) {
if (!deadline) {
deadline = Date.now() - 500;
}
return deadline - Date.now();
};
getContext() {
if (this.#context === null) {
DebugAndLog.warn("Context for request is null but was requested. Set context along with event when constructing ClientRequest object");
}
return this.#context;
};
getEvent() {
return this.#event;
};
#extractResourceAndPath() {
const {resource, path} = this.getEvent();
let resourceIndex = [];
const resourcesAndPaths = {
resource: '',
resourceArray: [],
path: '',
pathArray: []
};
/* We want to use reqContext.resourcePath to create a resourcePath and resourceArray, and we want to use path to create a path and pathArray
For resourcePathArray, we want to split resourcePath on / and remove any empty strings. We also want to lowercase any element that is not surrounded with {}
We want to add the index of any resource element that is surrounded with {} to the resourceIndex array.
For pathArray we want to split on / and remove any empty strings. We also want to lowercase any element that is not at an index listed in the resourceIndex array
*/
if (resource) {
const resourceArray = resource.split('/').filter((element) => element !== '');
resourceArray.forEach((element, index) => {
if (element.startsWith('{') && element.endsWith('}')) {
resourceIndex.push(index);
resourcesAndPaths.resourceArray.push(element);
} else {
resourcesAndPaths.resourceArray.push(element.toLowerCase());
}
});
resourcesAndPaths.resource = resourcesAndPaths.resourceArray.join('/');
}
if (path) {
const pathArray = path.split('/').filter((element) => element !== '');
pathArray.forEach((element, index) => {
if (!resourceIndex.includes(index)) {
resourcesAndPaths.pathArray.push(element.toLowerCase());
} else {
resourcesAndPaths.pathArray.push(element);
}
});
resourcesAndPaths.path = resourcesAndPaths.pathArray.join('/');
}
return resourcesAndPaths;
}
};
module.exports = ClientRequest;