actionhero
Version:
The reusable, scalable, and quick node.js API server for stateless and stateful applications
464 lines (402 loc) • 13.8 kB
text/typescript
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);
}
}
}