@agiledigital/serverless-sns-sqs-lambda
Version:
serverless plugin to make serverless-sns-sqs-lambda events
430 lines • 24.3 kB
JavaScript
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
Object.defineProperty(exports, "__esModule", { value: true });
// Future work: Properly type the file
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* A regular expression that matches AWS KMS arns
*/
var kmsArnRegex = /^arn:aws:kms:.*:.*:key\/.+$/;
/**
* Parse a value into a number or set it to a default value.
*
* @param {string|number|null|undefined} intString value possibly in string
* @param {*} defaultInt the default value if `intString` can't be parsed
*/
var parseIntOr = function (intString, defaultInt) {
if (intString === null || intString === undefined) {
return defaultInt;
}
try {
return parseInt(intString.toString(), 10);
}
catch (_a) {
return defaultInt;
}
};
/**
* Converts a string from camelCase to PascalCase. Basically, it just
* capitalises the first letter.
*
* @param {string} camelCase camelCase string
*/
var pascalCase = function (camelCase) {
return camelCase.slice(0, 1).toUpperCase() + camelCase.slice(1);
};
var pascalCaseAllKeys = function (jsonObject) {
return Object.keys(jsonObject).reduce(function (acc, key) {
var _a;
return (__assign(__assign({}, acc), (_a = {}, _a[pascalCase(key)] = jsonObject[key], _a)));
}, {});
};
var validateQueueName = function (queueName) {
if (queueName.length > 80) {
throw new Error("Generated queue name [".concat(queueName, "] is longer than 80 characters long and may be truncated by AWS, causing naming collisions. Try a shorter prefix or name, or try the hashQueueName config option."));
}
return queueName;
};
/**
* Returns true if the provided string looks like an KMS ARN, otherwise false
* @param possibleArn the candidate string
* @returns true if the provided string looks like a KMS ARN, otherwise false
*/
var isKmsArn = function (possibleArn) {
return kmsArnRegex.test(possibleArn);
};
/**
* Adds a resource block to a template, ensuring uniqueness.
* @param template the serverless template
* @param logicalId the logical ID (resource key) for the resource
* @param resourceDefinition the definition of the resource
*/
var addResource = function (template, logicalId, resourceDefinition) {
if (logicalId in template.Resources) {
throw new Error("Generated logical ID [".concat(logicalId, "] already exists in resources definition. Ensure that the snsSqs event definition has a unique name property."));
}
template.Resources[logicalId] = resourceDefinition;
};
/**
* The ServerlessSnsSqsLambda plugin looks for functions that contain an
* `snsSqs` event and adds the necessary resources for the Lambda to subscribe
* to the SNS topics with error handling and retry functionality built in.
*
* An example configuration might look like:
*
* functions:
* processEvent:
* handler: handler.handler
* events:
* - snsSqs:
* name: ResourcePrefix
* topicArn: ${self:custom.topicArn}
* batchSize: 2
* maximumBatchingWindowInSeconds: 30
* maxRetryCount: 2
* kmsMasterKeyId: alias/aws/sqs
* kmsDataKeyReusePeriodSeconds: 600
* deadLetterMessageRetentionPeriodSeconds: 1209600
* deadLetterQueueEnabled: true
* visibilityTimeout: 120
* rawMessageDelivery: true
* enabled: false
* fifo: false
* filterPolicy:
* pet:
* - dog
* - cat
*/
var ServerlessSnsSqsLambda = /** @class */ (function () {
/**
* @param {*} serverless
* @param {*} options
*/
function ServerlessSnsSqsLambda(serverless, options) {
this.serverless = serverless;
this.options = options;
this.provider = serverless ? serverless.getProvider("aws") : null;
this.custom = serverless.service ? serverless.service.custom : null;
this.serviceName = serverless.service.service;
// Aligns with AWS provider order of precedence: https://github.com/serverless/serverless/blob/46d090a302b9f7f4a3cf479695489b7ffc46b75b/lib/plugins/aws/provider.js#L1728
// Serverless will set one of these to "dev" if it is not provided so we don't need an explicit fallback
this.stage =
this.options.stage ||
this.serverless.config.stage ||
this.serverless.service.provider.stage;
serverless.configSchemaHandler.defineFunctionEvent("aws", "snsSqs", {
type: "object",
properties: {
name: { type: "string" },
topicArn: { $ref: "#/definitions/awsArn" },
prefix: { type: "string" },
omitPhysicalId: { type: "boolean" },
batchSize: { type: "number", minimum: 1, maximum: 10000 },
maximumBatchingWindowInSeconds: {
type: "number",
minimum: 0,
maximum: 300
},
maxRetryCount: { type: "number" },
kmsMasterKeyId: {
anyOf: [{ type: "string" }, { $ref: "#/definitions/awsArn" }]
},
kmsDataKeyReusePeriodSeconds: {
type: "number",
minimum: 60,
maximum: 86400
},
visibilityTimeout: {
type: "number",
minimum: 0,
maximum: 43200
},
deadLetterMessageRetentionPeriodSeconds: {
type: "number",
minimum: 60,
maximum: 1209600
},
deadLetterQueueEnabled: { type: "boolean" },
rawMessageDelivery: { type: "boolean" },
enabled: { type: "boolean" },
fifo: { type: "boolean" },
filterPolicy: { type: "object" },
mainQueueOverride: { type: "object" },
deadLetterQueueOverride: { type: "object" },
eventSourceMappingOverride: { type: "object" },
subscriptionOverride: { type: "object" }
},
required: ["name", "topicArn"],
additionalProperties: false
});
if (!this.provider) {
throw new Error("This plugin must be used with AWS");
}
this.hooks = {
"aws:package:finalize:mergeCustomProviderResources": this.modifyTemplate.bind(this)
};
}
/**
* Mutate the CloudFormation template, adding the necessary resources for
* the Lambda to subscribe to the SNS topics with error handling and retry
* functionality built in.
*/
ServerlessSnsSqsLambda.prototype.modifyTemplate = function () {
var _this = this;
var functions = this.serverless.service.functions;
var template = this.serverless.service.provider.compiledCloudFormationTemplate;
Object.keys(functions).forEach(function (funcKey) {
var func = functions[funcKey];
if (func.events) {
func.events.forEach(function (event) {
if (event.snsSqs) {
if (_this.options.verbose) {
console.info("Adding snsSqs event handler [".concat(JSON.stringify(event.snsSqs), "]"));
}
_this.addSnsSqsResources(template, funcKey, _this.stage, event.snsSqs);
}
});
}
});
};
/**
*
* @param {object} template the template which gets mutated
* @param {string} funcName the name of the function from serverless config
* @param {string} stage the stage name from the serverless config
* @param {object} snsSqsConfig the configuration values from the snsSqs
* event portion of the serverless function config
*/
ServerlessSnsSqsLambda.prototype.addSnsSqsResources = function (template, funcName, stage, snsSqsConfig) {
var config = this.validateConfig(funcName, stage, snsSqsConfig);
[
this.addEventSourceMapping,
this.addEventDeadLetterQueue,
this.addEventQueue,
this.addEventQueuePolicy,
this.addTopicSubscription,
this.addLambdaSqsPermissions
].reduce(function (template, func) {
func(template, config);
return template;
}, template);
};
/**
* Validate the configuration values from the serverless config file,
* returning a config object that can be passed to the resource setup
* functions.
*
* @param {string} funcName the name of the function from serverless config
* @param {string} stage the stage name from the serverless config
* @param {object} config the configuration values from the snsSqs event
* portion of the serverless function config
*/
ServerlessSnsSqsLambda.prototype.validateConfig = function (funcName, stage, config) {
var _a, _b, _c, _d;
if (!config.topicArn || !config.name) {
throw new Error("Error:\nWhen creating an snsSqs handler, you must define the name and topicArn.\nIn function [".concat(funcName, "]:\n- name was [").concat(config.name, "]\n- topicArn was [").concat(config.topicArn, "].\n\nUsage\n-----\n\n functions:\n processEvent:\n handler: handler.handler\n events:\n - snsSqs:\n name: Event # required\n topicArn: !Ref TopicArn # required\n prefix: some-prefix # optional - default is `${this.serviceName}-${stage}-${funcNamePascalCase}`\n maxRetryCount: 2 # optional - default is 5\n batchSize: 1 # optional - default is 10\n batchWindow: 10 # optional - default is 0 (no batch window)\n kmsMasterKeyId: alias/aws/sqs # optional - default is none (no encryption)\n kmsDataKeyReusePeriodSeconds: 600 # optional - AWS default is 300 seconds\n deadLetterMessageRetentionPeriodSeconds: 1209600 # optional - AWS default is 345600 secs (4 days)\n deadLetterQueueEnabled: true # optional - default is enabled\n enabled: true # optional - AWS default is true\n fifo: false # optional - AWS default is false\n visibilityTimeout: 30 # optional - AWS default is 30 seconds\n rawMessageDelivery: false # optional - default is false\n filterPolicy:\n pet:\n - dog\n - cat\n\n # Overrides for generated CloudFormation templates\n # Mirrors the CloudFormation docs but uses camel case instead of title case\n #\n #\n # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-sqs-queues.html\n mainQueueOverride:\n maximumMessageSize: 1024\n ...\n deadLetterQueueOverride:\n maximumMessageSize: 1024\n ...\n # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html\n eventSourceMappingOverride:\n bisectBatchOnFunctionError: true\n # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sns-subscription.html\n subscriptionOverride:\n rawMessageDelivery: true\n\n"));
}
var funcNamePascalCase = pascalCase(funcName);
return __assign(__assign({}, config), { name: config.name, funcName: funcNamePascalCase, prefix: config.prefix || "".concat(this.serviceName, "-").concat(stage, "-").concat(funcNamePascalCase), batchSize: parseIntOr(config.batchSize, 10), maxRetryCount: parseIntOr(config.maxRetryCount, 5), kmsMasterKeyId: config.kmsMasterKeyId, kmsDataKeyReusePeriodSeconds: config.kmsDataKeyReusePeriodSeconds, deadLetterMessageRetentionPeriodSeconds: config.deadLetterMessageRetentionPeriodSeconds, deadLetterQueueEnabled: config.deadLetterQueueEnabled !== undefined
? config.deadLetterQueueEnabled
: true, enabled: config.enabled, fifo: config.fifo !== undefined ? config.fifo : false, visibilityTimeout: config.visibilityTimeout, rawMessageDelivery: config.rawMessageDelivery !== undefined
? config.rawMessageDelivery
: false, mainQueueOverride: (_a = config.mainQueueOverride) !== null && _a !== void 0 ? _a : {}, deadLetterQueueOverride: (_b = config.deadLetterQueueOverride) !== null && _b !== void 0 ? _b : {}, eventSourceMappingOverride: (_c = config.eventSourceMappingOverride) !== null && _c !== void 0 ? _c : {}, subscriptionOverride: (_d = config.subscriptionOverride) !== null && _d !== void 0 ? _d : {} });
};
/**
* Add the Event Source Mapping which sets up the message handler to pull
* events of the Event Queue and handle them.
*
* @param {object} template the template which gets mutated
* @param {{funcName, name, prefix, batchSize, enabled}} config including name of the queue
* and the resource prefix
*/
ServerlessSnsSqsLambda.prototype.addEventSourceMapping = function (template, _a) {
var funcName = _a.funcName, name = _a.name, batchSize = _a.batchSize, maximumBatchingWindowInSeconds = _a.maximumBatchingWindowInSeconds, enabled = _a.enabled, eventSourceMappingOverride = _a.eventSourceMappingOverride;
var enabledWithDefault = enabled !== undefined ? enabled : true;
addResource(template, "".concat(funcName, "EventSourceMappingSQS").concat(name, "Queue"), {
Type: "AWS::Lambda::EventSourceMapping",
Properties: __assign({ BatchSize: batchSize, MaximumBatchingWindowInSeconds: maximumBatchingWindowInSeconds !== undefined
? maximumBatchingWindowInSeconds
: 0, EventSourceArn: { "Fn::GetAtt": ["".concat(name, "Queue"), "Arn"] }, FunctionName: { "Fn::GetAtt": ["".concat(funcName, "LambdaFunction"), "Arn"] }, Enabled: enabledWithDefault ? "True" : "False" }, pascalCaseAllKeys(eventSourceMappingOverride))
});
};
/**
* Add the Dead Letter Queue which will collect failed messages for later
* inspection and handling.
*
* @param {object} template the template which gets mutated
* @param {{name, prefix, kmsMasterKeyId, kmsDataKeyReusePeriodSeconds, deadLetterMessageRetentionPeriodSeconds }} config including name of the queue
* and the resource prefix
*/
ServerlessSnsSqsLambda.prototype.addEventDeadLetterQueue = function (template, _a) {
var name = _a.name, prefix = _a.prefix, fifo = _a.fifo, kmsMasterKeyId = _a.kmsMasterKeyId, kmsDataKeyReusePeriodSeconds = _a.kmsDataKeyReusePeriodSeconds, deadLetterMessageRetentionPeriodSeconds = _a.deadLetterMessageRetentionPeriodSeconds, deadLetterQueueOverride = _a.deadLetterQueueOverride, deadLetterQueueEnabled = _a.deadLetterQueueEnabled, omitPhysicalId = _a.omitPhysicalId;
if (!deadLetterQueueEnabled) {
return;
}
var candidateQueueName = "".concat(prefix).concat(name, "DeadLetterQueue").concat(fifo ? ".fifo" : "");
addResource(template, "".concat(name, "DeadLetterQueue"), {
Type: "AWS::SQS::Queue",
Properties: __assign(__assign(__assign(__assign(__assign(__assign({}, (omitPhysicalId
? {}
: { QueueName: validateQueueName(candidateQueueName) })), (fifo ? { FifoQueue: true } : {})), (kmsMasterKeyId !== undefined
? {
KmsMasterKeyId: kmsMasterKeyId
}
: {})), (kmsDataKeyReusePeriodSeconds !== undefined
? {
KmsDataKeyReusePeriodSeconds: kmsDataKeyReusePeriodSeconds
}
: {})), (deadLetterMessageRetentionPeriodSeconds !== undefined
? {
MessageRetentionPeriod: deadLetterMessageRetentionPeriodSeconds
}
: {})), pascalCaseAllKeys(deadLetterQueueOverride))
});
};
/**
* Add the event queue that will subscribe to the topic and collect the events
* from SNS as they arrive, holding them for processing.
*
* @param {object} template the template which gets mutated
* @param {{name, prefix, maxRetryCount, kmsMasterKeyId, kmsDataKeyReusePeriodSeconds, visibilityTimeout}} config including name of the queue,
* the resource prefix and the max retry count for message handler failures.
*/
ServerlessSnsSqsLambda.prototype.addEventQueue = function (template, _a) {
var name = _a.name, prefix = _a.prefix, fifo = _a.fifo, maxRetryCount = _a.maxRetryCount, kmsMasterKeyId = _a.kmsMasterKeyId, kmsDataKeyReusePeriodSeconds = _a.kmsDataKeyReusePeriodSeconds, visibilityTimeout = _a.visibilityTimeout, mainQueueOverride = _a.mainQueueOverride, omitPhysicalId = _a.omitPhysicalId, deadLetterQueueEnabled = _a.deadLetterQueueEnabled;
var candidateQueueName = "".concat(prefix).concat(name, "Queue").concat(fifo ? ".fifo" : "");
addResource(template, "".concat(name, "Queue"), {
Type: "AWS::SQS::Queue",
Properties: __assign(__assign(__assign(__assign(__assign(__assign(__assign({}, (omitPhysicalId
? {}
: { QueueName: validateQueueName(candidateQueueName) })), (fifo ? { FifoQueue: true } : {})), (deadLetterQueueEnabled
? {
RedrivePolicy: {
deadLetterTargetArn: {
"Fn::GetAtt": ["".concat(name, "DeadLetterQueue"), "Arn"]
},
maxReceiveCount: maxRetryCount
}
}
: {})), (kmsMasterKeyId !== undefined
? {
KmsMasterKeyId: kmsMasterKeyId
}
: {})), (kmsDataKeyReusePeriodSeconds !== undefined
? {
KmsDataKeyReusePeriodSeconds: kmsDataKeyReusePeriodSeconds
}
: {})), (visibilityTimeout !== undefined
? {
VisibilityTimeout: visibilityTimeout
}
: {})), pascalCaseAllKeys(mainQueueOverride))
});
};
/**
* Add a policy allowing the queue to subscribe to the SNS topic.
*
* @param {object} template the template which gets mutated
* @param {{name, prefix, topicArn}} config including name of the queue, the
* resource prefix and the arn of the topic
*/
ServerlessSnsSqsLambda.prototype.addEventQueuePolicy = function (template, _a) {
var name = _a.name, prefix = _a.prefix, topicArn = _a.topicArn;
addResource(template, "".concat(name, "QueuePolicy"), {
Type: "AWS::SQS::QueuePolicy",
Properties: {
PolicyDocument: {
Version: "2012-10-17",
Id: "".concat(prefix).concat(name, "Queue"),
Statement: [
{
Sid: "".concat(prefix).concat(name, "Sid"),
Effect: "Allow",
Principal: { Service: "sns.amazonaws.com" },
Action: "SQS:SendMessage",
Resource: { "Fn::GetAtt": ["".concat(name, "Queue"), "Arn"] },
Condition: { ArnEquals: { "aws:SourceArn": [topicArn] } }
}
]
},
Queues: [{ Ref: "".concat(name, "Queue") }]
}
});
};
/**
* Subscribe the newly created queue to the desired topic.
*
* @param {object} template the template which gets mutated
* @param {{name, topicArn, filterPolicy}} config including name of the queue,
* the arn of the topic and the filter policy for the subscription
*/
ServerlessSnsSqsLambda.prototype.addTopicSubscription = function (template, _a) {
var name = _a.name, topicArn = _a.topicArn, filterPolicy = _a.filterPolicy, rawMessageDelivery = _a.rawMessageDelivery, subscriptionOverride = _a.subscriptionOverride;
addResource(template, "Subscribe".concat(name, "Topic"), {
Type: "AWS::SNS::Subscription",
Properties: __assign(__assign(__assign({ Endpoint: { "Fn::GetAtt": ["".concat(name, "Queue"), "Arn"] }, Protocol: "sqs", TopicArn: topicArn }, (filterPolicy ? { FilterPolicy: filterPolicy } : {})), (rawMessageDelivery !== undefined
? {
RawMessageDelivery: rawMessageDelivery
}
: {})), pascalCaseAllKeys(subscriptionOverride))
});
};
/**
* Add permissions so that the SQS handler can access the queue.
*
* @param {object} template the template which gets mutated
* @param {{name, prefix}} config the name of the queue the lambda is subscribed to
*/
ServerlessSnsSqsLambda.prototype.addLambdaSqsPermissions = function (template, _a) {
var name = _a.name, kmsMasterKeyId = _a.kmsMasterKeyId, deadLetterQueueEnabled = _a.deadLetterQueueEnabled;
if (template.Resources.IamRoleLambdaExecution === undefined) {
// The user has set their own custom role ARN so the Serverless generated role is not generated
// We can safely skip this step because the owner of the custom role ARN is responsible for setting
// this the relevant policy to allow the lambda to access the queue.
return;
}
var queues = [{ "Fn::GetAtt": ["".concat(name, "Queue"), "Arn"] }];
if (deadLetterQueueEnabled) {
queues.push({ "Fn::GetAtt": ["".concat(name, "DeadLetterQueue"), "Arn"] });
}
template.Resources.IamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement.push({
Effect: "Allow",
Action: [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes"
],
Resource: queues
});
if (kmsMasterKeyId !== undefined && kmsMasterKeyId !== null) {
// TODO: Should we rename kmsMasterKeyId to make it clearer that it can accept an ARN?
var resource =
// If the key ID is an object, it is most likely a "Ref" or "GetAtt" so we should pass it straight through so it gets resolved by CloudFormation
// If an ARN is provided, pass it straight through too, because no processing is needed
// Otherwise if it isn't either of those things, it is probably an ID, so we need to
// transform it to an ARN to make the policy valid
typeof kmsMasterKeyId === "object" || isKmsArn(kmsMasterKeyId)
? kmsMasterKeyId
: "arn:aws:kms:::key/".concat(kmsMasterKeyId);
template.Resources.IamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement.push({
Effect: "Allow",
Action: ["kms:Decrypt"],
Resource: resource
});
}
};
return ServerlessSnsSqsLambda;
}());
exports.default = ServerlessSnsSqsLambda;
//# sourceMappingURL=serverless-sns-sqs-lambda.js.map