@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