somod-http-extension
Version:
SOMOD middleware to intercept and validate Lambda event for AWS APIGateway
209 lines (208 loc) • 7.82 kB
JavaScript
import { join } from "path";
import { FILE_ROUTES_HTTP_JSON, MIDDLEWARE_CONTEXT_KEY, PATH_HTTP_SCHEMAS } from "../../../lib/constants";
import { existsSync } from "fs";
import { readFile } from "fs/promises";
import { validate } from "decorated-ajv";
import { getHttpSchemaPath } from "../../../lib/utils";
import { decode } from "querystring";
import { BadRequestError, NoRouteFoundError } from "../../../lib/types";
import { pathToFileURL } from "url";
import fetch from "node-fetch";
let configuredRoutes = null;
const getConfiguredRoutes = async () => {
if (configuredRoutes === null) {
// NOTE: To Match the path used in prepare stage
const routesJsonPath = join(__dirname, PATH_HTTP_SCHEMAS, FILE_ROUTES_HTTP_JSON);
if (!existsSync(routesJsonPath)) {
throw new Error("Found no routes at " + routesJsonPath);
}
const routesStr = await readFile(routesJsonPath, { encoding: "utf8" });
const routes = JSON.parse(routesStr);
if (typeof routes !== "object") {
throw new Error("Invalid routes configuration in " + routesJsonPath);
}
configuredRoutes = routes;
}
return configuredRoutes;
};
const validators = {};
const loadValidator = async (path, method, key) => {
const validatorPath = getHttpSchemaPath(path, method, key);
if (validators[validatorPath] === undefined) {
try {
validators[validatorPath] = (await import(pathToFileURL(join(__dirname, PATH_HTTP_SCHEMAS, validatorPath)).toString())).default;
}
catch (e) {
// eslint-disable-next-line no-console
console.error("Error in loading validator", e);
// @ts-expect-error this is okay to assign the default validate function here
validators[validatorPath] = () => {
return true;
};
}
}
return validators[validatorPath];
};
const getMethodAndPath = (event) => {
const [method, path] = event.routeKey.split(" ");
return { path, method };
};
const getRouteConfig = async (path, method) => {
const configuredRoutes = await getConfiguredRoutes();
const routeConfig = configuredRoutes[path]?.[method];
if (routeConfig === undefined) {
throw new NoRouteFoundError(`No route defined for ${method} ${path}`);
}
return routeConfig;
};
const validateParameter = async (path, method, name, _in, value, required) => {
if (required && value === undefined) {
throw new Error(`Parameter ${name} must be present in ${_in}`);
}
if (value !== undefined) {
const validator = await loadValidator(path, method, { name, in: _in });
const violations = await validate(validator, value);
if (violations.length > 0) {
throw new BadRequestError(JSON.stringify({
message: `Invalid Parameter ${name} in ${_in}`,
errors: violations
}));
}
}
return value;
};
const validateParameters = async (path, method, routeConfig, event) => {
const validatedParameters = {
path: {},
query: {},
header: {}
};
if (routeConfig.parameters) {
await Promise.all(routeConfig.parameters.map(async (parameter) => {
let value = undefined;
if (parameter.in == "path") {
value = event.pathParameters?.[parameter.name];
}
else if (parameter.in == "query") {
value = event.queryStringParameters?.[parameter.name];
}
else if (parameter.in == "header") {
value = event.headers[parameter.name];
}
validatedParameters[parameter.in][parameter.name] =
await validateParameter(path, method, parameter.name, parameter.in, value, parameter.required);
}));
}
return validatedParameters;
};
const parseBody = (routeConfig, event) => {
let contentType = routeConfig.body?.parser;
if (contentType === undefined) {
if (event.headers["content-type"]?.includes("application/json")) {
contentType = "json";
}
else if (event.headers["content-type"]?.includes("application/x-www-form-urlencoded")) {
contentType = "formdata";
}
else {
contentType = "text";
}
}
let parsedBody = event.body || "";
if (contentType == "json") {
parsedBody = JSON.parse(parsedBody);
}
else if (contentType == "formdata") {
parsedBody = decode(parsedBody);
}
return parsedBody;
};
const validateBody = async (path, method, routeConfig, event) => {
let validatedBody = undefined;
if (routeConfig.body) {
const validator = await loadValidator(path, method, "body");
const parsedBody = parseBody(routeConfig, event);
const violations = await validate(validator, parsedBody);
if (violations.length > 0) {
throw new BadRequestError(JSON.stringify({
message: `Invalid Request Body`,
errors: violations
}));
}
validatedBody = parsedBody;
}
return validatedBody;
};
const sendLogsToAxiom = async (method, path, statusCode, event, error) => {
try {
const axiomIngestUrl = process.env.SOMOD_HTTP_AXIOM_INGEST_URL;
const axiomIngestToken = process.env.SOMOD_HTTP_AXIOM_INGEST_TOKEN;
if (axiomIngestUrl && axiomIngestToken) {
await fetch(axiomIngestUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${axiomIngestToken}`
},
body: JSON.stringify([
{
method,
path,
statusCode,
event,
error,
timestamp: new Date().toISOString()
}
])
});
}
}
catch (e) {
// eslint-disable-next-line no-console
console.error("Error sending logs to Axiom:", e);
}
};
const middleware = async (next, event) => {
try {
const { method, path } = getMethodAndPath(event);
const routeConfig = await getRouteConfig(path, method);
const validatedParameters = await validateParameters(path, method, routeConfig, event);
const validatedBody = await validateBody(path, method, routeConfig, event);
const somodHttpRequest = {
route: path,
method: method,
parameters: validatedParameters,
body: validatedBody
};
event.somodMiddlewareContext.set(MIDDLEWARE_CONTEXT_KEY, somodHttpRequest);
return await next();
}
catch (e) {
const { method, path } = getMethodAndPath(event);
const statusCode = e instanceof NoRouteFoundError
? 404
: e instanceof BadRequestError
? 400
: 500;
if (process.env.SOMOD_HTTP_LOG_AXIOM_ERROR === "true") {
await sendLogsToAxiom(method, path, statusCode, event, e);
}
//includes 500 errors also
if (process.env.SOMOD_HTTP_LOG_4XX === "true") {
// eslint-disable-next-line no-console
console.error("backend_api_error: ", statusCode, method, path, e);
}
if (statusCode === 500) {
return {
statusCode: 500
};
}
return {
statusCode: statusCode,
headers: { "Content-Type": "application/json" },
// eslint-disable-next-line no-console
body: e.message
};
}
};
export default middleware;