@middy/event-normalizer
Version:
Parse and normalize AWS events middleware for the middy framework
240 lines (224 loc) • 6.78 kB
JavaScript
// Copyright 2017 - 2026 will Farrell, Luciano Mammino, and Middy contributors.
// SPDX-License-Identifier: MIT
import { gunzipSync } from "node:zlib";
import { jsonSafeParse, validateOptions } from "@middy/util";
const name = "event-normalizer";
const pkg = `@middy/${name}`;
const defaults = {
wrapNumbers: undefined,
maxDecompressedBytes: 10 * 1024 * 1024, // 10 MiB
};
const optionSchema = {
type: "object",
properties: {
wrapNumbers: { type: "boolean" },
maxDecompressedBytes: { type: "integer", minimum: 1 },
},
additionalProperties: false,
};
export const eventNormalizerValidateOptions = (options) =>
validateOptions(pkg, optionSchema, options);
const eventNormalizerMiddleware = (opts = {}) => {
const options = { ...defaults, ...opts };
const eventNormalizerMiddlewareBefore = (request) => {
parseEvent(request.event, options);
};
return {
before: eventNormalizerMiddlewareBefore,
};
};
const parseEvent = (event, options) => {
// event.eventSource => aws:amq, aws:docdb, aws:kafka, SelfManagedKafka
// event.deliveryStreamArn => aws:lambda:events
let eventSource = event.eventSource ?? event.deliveryStreamArn;
// event.Records => default
// event.records => aws:lambda:events
// event.messages => aws:amq
// event.tasks => aws:s3:batch
// event.events => aws:docdb
const records =
event.Records ??
event.records ??
event.messages ??
event.tasks ??
event.events;
if (!Array.isArray(records)) {
// event.configRuleId => aws:config
// event.awslogs => aws:cloudwatch
// event['CodePipeline.job'] => aws:codepipeline
eventSource ??=
(event.configRuleId && "aws:config") ??
(event.awslogs && "aws:cloudwatch") ??
(event["CodePipeline.job"] && "aws:codepipeline");
if (eventSource) {
events[eventSource]?.(event, options);
}
return;
}
// record.eventSource => default
// record.EventSource => aws:sns
// record.s3Key => aws:s3:batch
eventSource ??=
records[0].eventSource ??
records[0].EventSource ??
(records[0].s3Key && "aws:s3:batch");
// Hoist the dispatch fn out of the loop so we look it up once per batch
// instead of once per record.
const fn = events[eventSource];
if (fn) {
for (const record of records) {
fn(record, options);
}
}
};
const normalizeS3KeyReplacePlus = /\+/g;
const events = {
// MQ (ActiveMQ)
"aws:amq": (message) => {
message.data = base64Parse(message.data);
},
"aws:cloudwatch": (event, options) => {
event.awslogs.data = jsonSafeParse(
gunzipSync(base64Decode(event.awslogs.data), {
maxOutputLength: options.maxDecompressedBytes,
}).toString("utf-8"),
);
},
"aws:codepipeline": (event) => {
event[
"CodePipeline.job"
].data.actionConfiguration.configuration.UserParameters = jsonSafeParse(
event["CodePipeline.job"].data.actionConfiguration.configuration
.UserParameters,
);
},
"aws:config": (event) => {
event.invokingEvent = jsonSafeParse(event.invokingEvent);
event.ruleParameters = jsonSafeParse(event.ruleParameters);
},
// Pass-through: records-shape sources with no encoded fields.
"aws:codecommit": () => {},
"aws:docdb": () => {},
"aws:ses": () => {},
"aws:dynamodb": (record, options) => {
record.dynamodb.Keys = unmarshall(record.dynamodb.Keys, options);
record.dynamodb.NewImage = unmarshall(record.dynamodb.NewImage, options);
record.dynamodb.OldImage = unmarshall(record.dynamodb.OldImage, options);
},
"aws:kafka": (event) => {
for (const topics of Object.values(event.records)) {
for (const topic of topics) {
topic.key &&= base64Parse(topic.key);
topic.value &&= base64Parse(topic.value);
}
}
},
// Kinesis Stream
"aws:kinesis": (record) => {
record.kinesis.data = base64Parse(record.kinesis.data);
},
// Kinesis Firehose
"aws:lambda:events": (record) => {
record.data = base64Parse(record.data);
},
// MQ (RabbitMQ)
"aws:rmq": (event) => {
for (const messages of Object.values(event.rmqMessagesByQueue)) {
for (const message of messages) {
message.data = base64Parse(message.data);
}
}
},
"aws:s3": (record) => {
record.s3.object.key = normalizeS3Key(record.s3.object.key);
},
"aws:s3:batch": (task) => {
task.s3Key = normalizeS3Key(task.s3Key);
},
SelfManagedKafka: (event) => {
events["aws:kafka"](event);
},
"aws:sns": (record, options) => {
record.Sns.Message = jsonSafeParse(record.Sns.Message);
parseEvent(record.Sns.Message, options);
},
"aws:sns:sqs": (record, options) => {
record.Message = jsonSafeParse(record.Message);
parseEvent(record.Message, options);
},
"aws:sqs": (record, options) => {
record.body = jsonSafeParse(record.body);
// SNS -> SQS Special Case
if (record.body.Type === "Notification") {
events["aws:sns:sqs"](record.body, options);
} else {
parseEvent(record.body, options);
}
},
};
const base64Decode = (data) => Buffer.from(data, "base64");
const base64Parse = (data) =>
jsonSafeParse(base64Decode(data).toString("utf-8"));
const normalizeS3Key = (key) =>
decodeURIComponent(key.replace(normalizeS3KeyReplacePlus, " ")); // decodeURIComponent(key.replaceAll('+', ' '))
// Start: AWS SDK unmarshall
// Reference: https://github.com/aws/aws-sdk-js-v3/blob/v3.113.0/packages/util-dynamodb/src/convertToNative.ts
const unmarshall = (data, options) => convertValue.M(data ?? {}, options);
const convertValue = {
NULL: () => null,
BOOL: Boolean,
N: (value, options) => {
if (options.wrapNumbers) {
return { value };
}
const num = Number(value);
if (
(Number.MAX_SAFE_INTEGER < num || num < Number.MIN_SAFE_INTEGER) &&
num !== Number.NEGATIVE_INFINITY &&
num !== Number.POSITIVE_INFINITY
) {
try {
return BigInt(value);
} catch (_err) {
throw new Error(
`${value} can't be converted to BigInt. Set options.wrapNumbers to get string value.`,
{
cause: {
package: pkg,
value,
},
},
);
}
}
return num;
},
B: (value) => value,
S: (value) => value,
L: (value, options) => value.map((item) => convertToNative(item, options)),
M: (value, options) => {
const obj = Object.create(null);
for (const key in value) {
obj[key] = convertToNative(value[key], options);
}
return obj;
},
NS: (value, options) => new Set(value.map((v) => convertValue.N(v, options))),
BS: (value) => new Set(value.map(convertValue.B)),
SS: (value) => new Set(value.map(convertValue.S)),
};
const convertToNative = (data, options) => {
for (const key in data) {
const fn = convertValue[key];
if (!fn) {
throw new Error(`Unsupported type passed: ${key}`, {
cause: { package: pkg },
});
}
const v = data[key];
if (typeof v === "undefined") continue;
return fn(v, options);
}
};
// End: AWS SDK unmarshall
export default eventNormalizerMiddleware;