UNPKG

actionhero

Version:

The reusable, scalable, and quick node.js API server for stateless and stateful applications

464 lines (402 loc) 13.8 kB
import * as dotProp from "dot-prop"; import { api } from "../index"; import { log, ActionheroLogLevel } from "../modules/log"; import { utils } from "../modules/utils"; import { config } from "./../modules/config"; import { Action } from "./action"; import { Connection } from "./connection"; import { Input } from "./input"; export enum ActionsStatus { Complete, GenericError, ServerShuttingDown, TooManyRequests, UnknownAction, UnsupportedServerType, MissingParams, ValidatorErrors, } export class ActionProcessor<ActionClass extends Action> { connection: Connection; action: ActionClass["name"]; toProcess: boolean; toRender: boolean; messageId: number | string; params: { action?: string; apiVersion?: string | number; [key: string]: any; }; // params: ActionClass["inputs"]; missingParams: Array<string>; validatorErrors: Array<string | Error>; actionStartTime: number; actionTemplate: ActionClass; working: boolean; response: { [key: string]: any; }; duration: number; actionStatus: ActionsStatus; // allow for setting of any value via middleware session: any; constructor(connection: Connection) { this.connection = connection; this.action = null; this.toProcess = true; this.toRender = true; this.messageId = connection.messageId || 0; this.params = Object.assign( { action: null, apiVersion: null }, connection.params, ); this.missingParams = []; this.validatorErrors = []; this.actionStartTime = null; this.actionTemplate = null; this.working = false; this.response = {}; this.duration = null; this.actionStatus = null; this.session = {}; } private incrementTotalActions(count = 1) { this.connection.totalActions = this.connection.totalActions + count; } private incrementPendingActions(count = 1) { this.connection.pendingActions = this.connection.pendingActions + count; if (this.connection.pendingActions < 0) { this.connection.pendingActions = 0; } } getPendingActionCount() { return this.connection.pendingActions; } private async completeAction( status: ActionsStatus, _error?: NodeJS.ErrnoException, ) { let error: NodeJS.ErrnoException | string = null; this.actionStatus = status; if (status === ActionsStatus.GenericError) { error = typeof config.errors.genericError === "function" ? await config.errors.genericError(this, _error) : _error; } else if (status === ActionsStatus.ServerShuttingDown) { error = await config.errors.serverShuttingDown(this); } else if (status === ActionsStatus.TooManyRequests) { error = await config.errors.tooManyPendingActions(this); } else if (status === ActionsStatus.UnknownAction) { error = await config.errors.unknownAction(this); } else if (status === ActionsStatus.UnsupportedServerType) { error = await config.errors.unsupportedServerType(this); } else if (status === ActionsStatus.MissingParams) { error = await config.errors.missingParams(this, this.missingParams); } else if (status === ActionsStatus.ValidatorErrors) { error = await config.errors.invalidParams(this, this.validatorErrors); } else if (status) { error = _error; } if (typeof error === "string") error = new Error(error); if (error && (typeof this.response === "string" || !this.response.error)) { if (typeof this.response === "string" || Array.isArray(this.response)) { //@ts-ignore this.response = error.toString(); } else { this.response.error = error; } } this.incrementPendingActions(-1); this.duration = new Date().getTime() - this.actionStartTime; this.working = false; this.logAndReportAction(status, error); return this; } private logAndReportAction( status: ActionsStatus, error: NodeJS.ErrnoException, ) { const { type, rawConnection } = this.connection; let logLevel: ActionheroLogLevel = "info"; if (this.actionTemplate && this.actionTemplate.logLevel) { logLevel = this.actionTemplate.logLevel; } const filteredParams = utils.filterObjectForLogging(this.params); let logLine = { to: this.connection.remoteIP, action: this.action, params: JSON.stringify(filteredParams), duration: this.duration, method: type === "web" ? rawConnection.method : undefined, pathname: type === "web" ? rawConnection.parsedURL.pathname : undefined, error: "", response: undefined as string, }; if (config.general.enableResponseLogging) { logLine.response = JSON.stringify( utils.filterResponseForLogging(this.response), ); } if (error) { let errorFields; const formatErrorLogLine = config.errors.serializers.actionProcessor || this.applyDefaultErrorLogLineFormat; ({ logLevel = "error", errorFields } = formatErrorLogLine(error)); logLine = { ...logLine, ...errorFields }; } log(`[ action @ ${this.connection.type} ]`, logLevel, logLine); if ( error && (status !== ActionsStatus.UnknownAction || config.errors.reportUnknownActions) ) { api.exceptionHandlers.action(error, logLine); } } applyDefaultErrorLogLineFormat(error: NodeJS.ErrnoException) { const logLevel = "error" as ActionheroLogLevel; const errorFields: { error: string } = { error: null }; if (error instanceof Error) { errorFields.error = error.toString(); Object.getOwnPropertyNames(error) .filter((prop) => prop !== "message") .sort((a, b) => (a === "stack" || b === "stack" ? -1 : 1)) //@ts-ignore .forEach((prop) => (errorFields[prop] = error[prop])); } else { try { errorFields.error = JSON.stringify(error); } catch (e) { errorFields.error = String(error); } } return { errorFields, logLevel }; } private async preProcessAction() { const processorNames = api.actions.globalMiddleware.slice(0); if (this.actionTemplate.middleware) { this.actionTemplate.middleware.forEach(function (m) { processorNames.push(m); }); } for (const i in processorNames) { const name = processorNames[i]; if (typeof api.actions.middleware[name].preProcessor === "function") { await api.actions.middleware[name].preProcessor(this); } } } private async postProcessAction() { const processorNames = api.actions.globalMiddleware.slice(0); if (this.actionTemplate.middleware) { this.actionTemplate.middleware.forEach((m) => { processorNames.push(m); }); } for (const i in processorNames) { const name = processorNames[i]; if (typeof api.actions.middleware[name].postProcessor === "function") { await api.actions.middleware[name].postProcessor(this); } } } private reduceParams(schemaKey?: string) { let inputs = this.actionTemplate.inputs || {}; let params = this.params; if (schemaKey) { inputs = this.actionTemplate.inputs[schemaKey].schema; params = this.params[schemaKey]; } const inputNames = Object.keys(inputs) || []; if (config.general.disableParamScrubbing !== true) { for (const p in params) { if ( api.params.globalSafeParams.indexOf(p) < 0 && inputNames.indexOf(p) < 0 ) { delete params[p]; } } } } private prepareStringMethod(method: string): Function { const cmdParts = method.split("."); const cmd = cmdParts.shift(); if (cmd !== "api") { throw new Error("cannot operate on a method outside of the api object"); } return dotProp.get(api, cmdParts.join(".")); } private async validateParam( props: Input, params: ActionProcessor<any>["params"], key: string, schemaKey: string, ) { // default if (params[key] === undefined && props.default !== undefined) { if (typeof props.default === "function") { params[key] = await props.default.call(this, params[key]); } else { params[key] = props.default; } } // formatter if (params[key] !== undefined && props.formatter !== undefined) { if (!Array.isArray(props.formatter)) { props.formatter = [props.formatter]; } for (const i in props.formatter) { const formatter = props.formatter[i]; if (typeof formatter === "function") { params[key] = await formatter.call(this, params[key], key); } else { const method = this.prepareStringMethod(formatter); params[key] = await method.call(this, params[key], key); } } } // validator if (params[key] !== undefined && props.validator !== undefined) { if (!Array.isArray(props.validator)) { props.validator = [props.validator]; } for (const j in props.validator) { const validator = props.validator[j]; let validatorResponse; try { if (typeof validator === "function") { validatorResponse = await validator.call(this, params[key], key); } else { const method = this.prepareStringMethod(validator); validatorResponse = await method.call(this, params[key], key); } // validator function returned nothing; assume param is OK if (validatorResponse === null || validatorResponse === undefined) { return; } // validator returned something that was not `true` if (validatorResponse !== true) { if (validatorResponse === false) { this.validatorErrors.push( new Error(`Input for parameter "${key}" failed validation!`), ); } else { this.validatorErrors.push(validatorResponse); } } } catch (error) { // validator threw an error this.validatorErrors.push(error); } } } // required if (props.required === true) { if (config.general.missingParamChecks.indexOf(params[key]) >= 0) { let missingKey = key; if (schemaKey) { missingKey = `${schemaKey}.${missingKey}`; } this.missingParams.push(missingKey); } } } private async validateParams(schemaKey?: string) { let inputs = this.actionTemplate.inputs || {}; let params = this.params; if (schemaKey) { inputs = this.actionTemplate.inputs[schemaKey].schema; params = this.params[schemaKey]; } for (const key in inputs) { const props = inputs[key]; await this.validateParam(props, params, key, schemaKey); if (props.schema && params[key]) { this.reduceParams(key); await this.validateParams(key); } } } lockParams() { this.params = Object.freeze(this.params); } async processAction( actionName?: string, apiVersion = this.params.apiVersion, ) { this.actionStartTime = new Date().getTime(); this.working = true; this.incrementTotalActions(); this.incrementPendingActions(); this.action = actionName || this.params.action; if (api.actions.versions[this.action]) { if (!apiVersion) { apiVersion = api.actions.versions[this.action][ api.actions.versions[this.action].length - 1 ]; } //@ts-ignore this.actionTemplate = api.actions.actions[this.action][apiVersion]; // send back the version we use to send in the api response if (!this.params.apiVersion) this.params.apiVersion = apiVersion; } if (api.running !== true) { return this.completeAction(ActionsStatus.ServerShuttingDown); } if (this.getPendingActionCount() > config.general.simultaneousActions) { return this.completeAction(ActionsStatus.TooManyRequests); } if (!this.action || !this.actionTemplate) { return this.completeAction(ActionsStatus.UnknownAction); } if ( this.actionTemplate.blockedConnectionTypes && this.actionTemplate.blockedConnectionTypes.indexOf( this.connection.type, ) >= 0 ) { return this.completeAction(ActionsStatus.UnsupportedServerType); } return this.runAction(); } private async runAction() { try { const preProcessResponse = await this.preProcessAction(); if (preProcessResponse !== undefined && preProcessResponse !== null) { Object.assign(this.response, preProcessResponse); } await this.reduceParams(); await this.validateParams(); this.lockParams(); } catch (error) { return this.completeAction(ActionsStatus.GenericError, error); } if (this.missingParams.length > 0) { return this.completeAction(ActionsStatus.MissingParams); } if (this.validatorErrors.length > 0) { return this.completeAction(ActionsStatus.ValidatorErrors); } if (this.toProcess === true) { try { const actionResponse = await this.actionTemplate.run(this); if (actionResponse !== undefined && actionResponse !== null) { Object.assign(this.response, actionResponse); } const postProcessResponse = await this.postProcessAction(); if (postProcessResponse !== undefined && postProcessResponse !== null) { Object.assign(this.response, postProcessResponse); } return this.completeAction(ActionsStatus.Complete); } catch (error) { return this.completeAction(ActionsStatus.GenericError, error); } } else { return this.completeAction(ActionsStatus.Complete); } } }