Repository: HyperBrain/serverless-aws-alias (js)

Issue:
Support alias in SNS Topic subscription
Hello,
When you create **SNS Topic** in **AWS Console** you can add _Lambda subscription_ with alias.
Now if you deploys different aliases - subscription will be always **${functionName}**.
Can we do like this  **${functionName}:${alias}** ?
Is it possible?
http://prntscr.com/fcbdfc
First subscription - it's what I need
Second subscription - current state.

Thank you.

Pull request description:
Transform SNS elements and move them to the alias stack.
Closes #43 

SNS Topics and subscriptions are now part of the alias, i.e. the topic name is appended with the alias name and the lambda subscriptions point to the associated function aliases.
This solution follows the discussion in #43 

Patch:
diff --git a/README.md b/README.md
index b7be99f..3f63130 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,23 @@ automatically.
 Event subscriptions that are defined for a lambda function will be deployed per
 alias, i.e. the event will trigger the correct deployed aliased function.
 
+### SNS
+
+Subscriptions to SNS topics can be implicitly defined by adding an `sns` event to
+any existing lambda function definition. Serverless will create the topic for you
+and add a subscription to the deployed function.
+
+With the alias plugin the subscription will be per alias. Additionally the created
+topic is renamed and the alias name is added (e.g. myTopic-myAlias). This is done
+because SNS topics are independent per stage. Imagine you want to introduce a new
+topic or change the data/payload format of an existing one. Just attaching different
+aliases to one central topic would eventually break the system, as functions from
+different stages will receive the new data format. The topic-per-alias approach
+effectively solves the problem.
+
+If you want to refer to the topic programmatically, you just can add `-${process.env.SERVERLESS_ALIAS}`
+to the base topic name.
+
 ### Use with global resources
 
 Event subscriptions can reference resources that are available throughout all
diff --git a/lib/aliasRestructureStack.js b/lib/aliasRestructureStack.js
index 3dc146e..ac7d56e 100644
--- a/lib/aliasRestructureStack.js
+++ b/lib/aliasRestructureStack.js
@@ -19,6 +19,7 @@ const userResources = require('./stackops/userResources');
 const lambdaRole = require('./stackops/lambdaRole');
 const events = require('./stackops/events');
 const cwEvents = require('./stackops/cwEvents');
+const snsEvents = require('./stackops/snsEvents');
 
 module.exports = {
 
@@ -50,6 +51,10 @@ module.exports = {
 		return cwEvents.call(this, currentTemplate, aliasStackTemplates, currentAliasStackTemplate);
 	},
 
+	aliasHandleSNSEvents(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) {
+		return snsEvents.call(this, currentTemplate, aliasStackTemplates, currentAliasStackTemplate);
+	},
+
 	aliasFinalize(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) {
 		const stageStack = this._serverless.service.provider.compiledCloudFormationTemplate;
 		const aliasStack = this._serverless.service.provider.compiledCloudFormationAliasTemplate;
@@ -84,6 +89,7 @@ module.exports = {
 		.spread(this.aliasHandleApiGateway)
 		.spread(this.aliasHandleEvents)
 		.spread(this.aliasHandleCWEvents)
+		.spread(this.aliasHandleSNSEvents)
 		.spread(this.aliasFinalize)
 		.then(() => BbPromise.resolve());
 	}
diff --git a/lib/stackops/snsEvents.js b/lib/stackops/snsEvents.js
new file mode 100644
index 0000000..fa89e51
--- /dev/null
+++ b/lib/stackops/snsEvents.js
@@ -0,0 +1,79 @@
+'use strict';
+
+/**
+ * Handle SNS Lambda subscriptions.
+ */
+
+const _ = require('lodash');
+const BbPromise = require('bluebird');
+const utils = require('../utils');
+
+module.exports = function(currentTemplate, aliasStackTemplates, currentAliasStackTemplate) {
+	const stageStack = this._serverless.service.provider.compiledCloudFormationTemplate;
+	const aliasStack = this._serverless.service.provider.compiledCloudFormationAliasTemplate;
+
+	this.options.verbose && this._serverless.cli.log('Processing SNS Lambda subscriptions');
+
+	const aliasResources = [];
+
+	const aliases = _.assign({}, _.pickBy(aliasStack.Resources, [ 'Type', 'AWS::Lambda::Alias' ]));
+	const versions = _.assign({}, _.pickBy(aliasStack.Resources, [ 'Type', 'AWS::Lambda::Version' ]));
+
+	// Add alias name to topics to disambiguate behavior
+	const snsTopics =
+			_.assign({},
+				_.pickBy(stageStack.Resources, [ 'Type', 'AWS::SNS::Topic' ]));
+
+	_.forOwn(snsTopics, (topic, name) => {
+		topic.DependsOn = topic.DependsOn || [];
+		// Remap lambda subscriptions
+		const lambdaSubscriptions = _.pickBy(topic.Properties.Subscription, ['Protocol', 'lambda']);
+		_.forOwn(lambdaSubscriptions, subscription => {
+			const functionNameRef = utils.findAllReferences(_.get(subscription, 'Endpoint'));
+			const functionName = _.replace(_.get(functionNameRef, '[0].ref', ''), /LambdaFunction$/, '');
+			const versionName = _.find(_.keys(versions), version => _.startsWith(version, functionName));
+			const aliasName = _.find(_.keys(aliases), alias => _.startsWith(alias, functionName));
+
+			subscription.Endpoint = { Ref: aliasName };
+
+			// Add dependency on function version
+			topic.DependsOn.push(versionName);
+			topic.DependsOn.push(aliasName);
+		});
+
+		topic.Properties.TopicName = `${topic.Properties.TopicName}-${this._alias}`;
+
+		delete stageStack.Resources[name];
+	});
+
+	// Fetch lambda permissions. These have to be updated later to allow the aliased functions.
+	const snsLambdaPermissions =
+			_.assign({},
+				_.pickBy(_.pickBy(stageStack.Resources, [ 'Type', 'AWS::Lambda::Permission' ]),
+				[ 'Properties.Principal', 'sns.amazonaws.com' ]));
+
+	// Adjust permission to reference the function aliases
+	_.forOwn(snsLambdaPermissions, (permission, name) => {
+		const functionName = _.replace(name, /LambdaPermission.*$/, '');
+		const versionName = _.find(_.keys(versions), version => _.startsWith(version, functionName));
+		const aliasName = _.find(_.keys(aliases), alias => _.startsWith(alias, functionName));
+
+		// Adjust references and alias permissions
+		permission.Properties.FunctionName = { Ref: aliasName };
+		const sourceArn = _.get(permission.Properties, 'SourceArn.Fn::Join[1]', []);
+		sourceArn.push(`-${this._alias}`);
+
+		// Add dependency on function version
+		permission.DependsOn = [ versionName, aliasName ];
+
+		delete stageStack.Resources[name];
+	});
+
+	// Add all alias stack owned resources
+	aliasResources.push(snsTopics);
+	aliasResources.push(snsLambdaPermissions);
+
+	_.forEach(aliasResources, resource => _.assign(aliasStack.Resources, resource));
+
+	return BbPromise.resolve([ currentTemplate, aliasStackTemplates, currentAliasStackTemplate ]);
+};


Test patch:
diff --git a/test/aliasRestructureStack.test.js b/test/aliasRestructureStack.test.js
index c3f35f5..6b733d9 100644
--- a/test/aliasRestructureStack.test.js
+++ b/test/aliasRestructureStack.test.js
@@ -91,6 +91,7 @@ describe('aliasRestructureStack', () => {
 			const aliasHandleApiGatewaySpy = sandbox.spy(awsAlias, 'aliasHandleApiGateway');
 			const aliasHandleEventsSpy = sandbox.spy(awsAlias, 'aliasHandleEvents');
 			const aliasHandleCWEventsSpy = sandbox.spy(awsAlias, 'aliasHandleCWEvents');
+			const aliasHandleSNSEventsSpy = sandbox.spy(awsAlias, 'aliasHandleSNSEvents');
 			const aliasFinalizeSpy = sandbox.spy(awsAlias, 'aliasFinalize');
 
 			const currentTemplate = require('./data/sls-stack-2.json');
@@ -107,6 +108,7 @@ describe('aliasRestructureStack', () => {
 				expect(aliasHandleApiGatewaySpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate),
 				expect(aliasHandleEventsSpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate),
 				expect(aliasHandleCWEventsSpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate),
+				expect(aliasHandleSNSEventsSpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate),
 				expect(aliasFinalizeSpy).to.have.been.calledWithExactly(currentTemplate, [ aliasTemplate ], currentAliasStackTemplate),
 			]));
 		});
diff --git a/test/data/sns-stack.json b/test/data/sns-stack.json
new file mode 100644
index 0000000..55ebbd4
--- /dev/null
+++ b/test/data/sns-stack.json
@@ -0,0 +1,358 @@
+{
+  "AWSTemplateFormatVersion": "2010-09-09",
+  "Description": "The AWS CloudFormation template for this Serverless application",
+  "Resources": {
+    "ServerlessDeploymentBucket": {
+      "Type": "AWS::S3::Bucket"
+    },
+    "Testfct1LogGroup": {
+      "Type": "AWS::Logs::LogGroup",
+      "Properties": {
+        "LogGroupName": "/aws/lambda/sls-test-project-dev-testfct1"
+      }
+    },
+    "IamRoleLambdaExecution": {
+      "Type": "AWS::IAM::Role",
+      "Properties": {
+        "AssumeRolePolicyDocument": {
+          "Version": "2012-10-17",
+          "Statement": [
+            {
+              "Effect": "Allow",
+              "Principal": {
+                "Service": [
+                  "lambda.amazonaws.com"
+                ]
+              },
+              "Action": [
+                "sts:AssumeRole"
+              ]
+            }
+          ]
+        },
+        "Policies": [
+          {
+            "PolicyName": {
+              "Fn::Join": [
+                "-",
+                [
+                  "dev",
+                  "sls-test-project",
+                  "lambda"
+                ]
+              ]
+            },
+            "PolicyDocument": {
+              "Version": "2012-10-17",
+              "Statement": [
+                {
+                  "Effect": "Allow",
+                  "Action": [
+                    "logs:CreateLogStream"
+                  ],
+                  "Resource": [
+                    {
+                      "Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/sls-test-project-dev-testfct1:*"
+                    }
+                  ]
+                },
+                {
+                  "Effect": "Allow",
+                  "Action": [
+                    "logs:PutLogEvents"
+                  ],
+                  "Resource": [
+                    {
+                      "Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/sls-test-project-dev-testfct1:*:*"
+                    }
+                  ]
+                },
+                {
+                  "Effect": "Allow",
+                  "Action": [
+                    "dynamodb:*"
+                  ],
+                  "Resource": [
+                    {
+                      "Fn::Join": [
+                        "/",
+                        [
+                          {
+                            "Fn::Join": [
+                              ":",
+                              [
+                                "arn:aws:dynamodb",
+                                {
+                                  "Ref": "AWS::Region"
+                                },
+                                {
+                                  "Ref": "AWS::AccountId"
+                                },
+                                "table"
+                              ]
+                            ]
+                          },
+                          {
+                            "Ref": "TestDynamoDbTable"
+                          }
+                        ]
+                      ]
+                    }
+                  ]
+                },
+                {
+                  "Effect": "Allow",
+                  "Action": [
+                    "dynamodb:GetRecords",
+                    "dynamodb:GetShardIterator",
+                    "dynamodb:DescribeStream",
+                    "dynamodb:ListStreams"
+                  ],
+                  "Resource": [
+                    {
+                      "Fn::GetAtt": [
+                        "TestDynamoDbTable",
+                        "StreamArn"
+                      ]
+                    }
+                  ]
+                }
+              ]
+            }
+          }
+        ],
+        "Path": "/",
+        "RoleName": {
+          "Fn::Join": [
+            "-",
+            [
+              "sls-test-project",
+              "dev",
+              "us-east-1",
+              "lambdaRole"
+            ]
+          ]
+        }
+      }
+    },
+    "Testfct1LambdaFunction": {
+      "Type": "AWS::Lambda::Function",
+      "Properties": {
+        "Code": {
+          "S3Bucket": {
+            "Ref": "ServerlessDeploymentBucket"
+          },
+          "S3Key": "serverless/sls-test-project/dev/1496054947737-2017-05-29T10:49:07.737Z/sls-test-project.zip"
+        },
+        "FunctionName": "sls-test-project-dev-testfct1",
+        "Handler": "handlers/testfct1/handler.handle",
+        "MemorySize": 512,
+        "Role": {
+          "Fn::GetAtt": [
+            "IamRoleLambdaExecution",
+            "Arn"
+          ]
+        },
+        "Runtime": "nodejs4.3",
+        "Timeout": 15,
+        "Description": "Echo function echoes alias",
+        "Environment": {
+          "Variables": {
+            "SERVERLESS_PROJECT_NAME": "sls-test-project",
+            "SERVERLESS_PROJECT": "sls-test-project",
+            "SERVERLESS_STAGE": "dev",
+            "SERVERLESS_REGION": "us-east-1",
+            "TEST_TABLE_NAME": {
+              "Ref": "TestDynamoDbTable"
+            }
+          }
+        }
+      },
+      "DependsOn": [
+        "Testfct1LogGroup",
+        "IamRoleLambdaExecution"
+      ]
+    },
+    "ApiGatewayRestApi": {
+      "Type": "AWS::ApiGateway::RestApi",
+      "Properties": {
+        "Name": "dev-sls-test-project"
+      }
+    },
+    "ApiGatewayResourceFunc1": {
+      "Type": "AWS::ApiGateway::Resource",
+      "Properties": {
+        "ParentId": {
+          "Fn::GetAtt": [
+            "ApiGatewayRestApi",
+            "RootResourceId"
+          ]
+        },
+        "PathPart": "func1",
+        "RestApiId": {
+          "Ref": "ApiGatewayRestApi"
+        }
+      }
+    },
+    "ApiGatewayMethodFunc1Get": {
+      "Type": "AWS::ApiGateway::Method",
+      "Properties": {
+        "HttpMethod": "GET",
+        "RequestParameters": {},
+        "ResourceId": {
+          "Ref": "ApiGatewayResourceFunc1"
+        },
+        "RestApiId": {
+          "Ref": "ApiGatewayRestApi"
+        },
+        "AuthorizationType": "NONE",
+        "Integration": {
+          "IntegrationHttpMethod": "POST",
+          "Type": "AWS_PROXY",
+          "Uri": {
+            "Fn::Join": [
+              "",
+              [
+                "arn:aws:apigateway:",
+                {
+                  "Ref": "AWS::Region"
+                },
+                ":lambda:path/2015-03-31/functions/",
+                {
+                  "Fn::GetAtt": [
+                    "Testfct1LambdaFunction",
+                    "Arn"
+                  ]
+                },
+                "/invocations"
+              ]
+            ]
+          }
+        },
+        "MethodResponses": []
+      }
+    },
+    "SNSTopicSlstestprojecttopic": {
+      "Type": "AWS::SNS::Topic",
+      "Properties": {
+        "TopicName": "sls-test-project-topic",
+        "DisplayName": "",
+        "Subscription": [
+          {
+            "Endpoint": {
+              "Fn::GetAtt": [
+                "Testfct1LambdaFunction",
+                "Arn"
+              ]
+            },
+            "Protocol": "lambda"
+          }
+        ]
+      }
+    },
+    "Testfct1LambdaPermissionSlstestprojecttopicSNS": {
+      "Type": "AWS::Lambda::Permission",
+      "Properties": {
+        "FunctionName": {
+          "Fn::GetAtt": [
+            "Testfct1LambdaFunction",
+            "Arn"
+          ]
+        },
+        "Action": "lambda:InvokeFunction",
+        "Principal": "sns.amazonaws.com",
+        "SourceArn": {
+          "Fn::Join": [
+            "",
+            [
+              "arn:aws:sns:",
+              {
+                "Ref": "AWS::Region"
+              },
+              ":",
+              {
+                "Ref": "AWS::AccountId"
+              },
+              ":",
+              "sls-test-project-topic"
+            ]
+          ]
+        }
+      }
+    },
+    "Testfct1EventSourceMappingDynamodbTestDynamoDbTable": {
+      "Type": "AWS::Lambda::EventSourceMapping",
+      "DependsOn": "IamRoleLambdaExecution",
+      "Properties": {
+        "BatchSize": 10,
+        "EventSourceArn": {
+          "Fn::GetAtt": [
+            "TestDynamoDbTable",
+            "StreamArn"
+          ]
+        },
+        "FunctionName": {
+          "Fn::GetAtt": [
+            "Testfct1LambdaFunction",
+            "Arn"
+          ]
+        },
+        "StartingPosition": "TRIM_HORIZON",
+        "Enabled": "True"
+      }
+    },
+    "TestDynamoDbTable": {
+      "Type": "AWS::DynamoDB::Table",
+      "DeletionPolicy": "Delete",
+      "Properties": {
+        "AttributeDefinitions": [
+          {
+            "AttributeName": "myKey",
+            "AttributeType": "S"
+          }
+        ],
+        "KeySchema": [
+          {
+            "AttributeName": "myKey",
+            "KeyType": "HASH"
+          }
+        ],
+        "ProvisionedThroughput": {
+          "ReadCapacityUnits": 1,
+          "WriteCapacityUnits": 1
+        },
+        "StreamSpecification": {
+          "StreamViewType": "NEW_AND_OLD_IMAGES"
+        }
+      }
+    }
+  },
+  "Outputs": {
+    "ServerlessDeploymentBucketName": {
+      "Value": {
+        "Ref": "ServerlessDeploymentBucket"
+      }
+    },
+    "ServiceEndpoint": {
+      "Description": "URL of the service endpoint",
+      "Value": {
+        "Fn::Join": [
+          "",
+          [
+            "https://",
+            {
+              "Ref": "ApiGatewayRestApi"
+            },
+            ".execute-api.us-east-1.amazonaws.com/dev"
+          ]
+        ]
+      }
+    },
+    "TestDynamoDbTableName": {
+      "Description": "Test DynamoDB Table Name",
+      "Value": {
+        "Ref": "TestDynamoDbTable"
+      }
+    }
+  }
+}
diff --git a/test/stackops/snsEvents.test.js b/test/stackops/snsEvents.test.js
new file mode 100644
index 0000000..7e5c3bd
--- /dev/null
+++ b/test/stackops/snsEvents.test.js
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Unit tests for SNS events.
+ */
+
+const getInstalledPath = require('get-installed-path');
+const BbPromise = require('bluebird');
+const chai = require('chai');
+const sinon = require('sinon');
+const AWSAlias = require('../../index');
+
+const serverlessPath = getInstalledPath.sync('serverless', { local: true });
+const AwsProvider = require(`${serverlessPath}/lib/plugins/aws/provider/awsProvider`);
+const Serverless = require(`${serverlessPath}/lib/Serverless`);
+
+chai.use(require('chai-as-promised'));
+chai.use(require('sinon-chai'));
+const expect = chai.expect;
+
+describe('SNS Events', () => {
+	let serverless;
+	let options;
+	let awsAlias;
+	// Sinon and stubs for SLS CF access
+	let sandbox;
+	let logStub;
+
+	before(() => {
+		sandbox = sinon.sandbox.create();
+	});
+
+	beforeEach(() => {
+		options = {
+			alias: 'myAlias',
+			stage: 'myStage',
+			region: 'us-east-1',
+		};
+		serverless = new Serverless(options);
+		serverless.setProvider('aws', new AwsProvider(serverless));
+		serverless.cli = new serverless.classes.CLI(serverless);
+		serverless.service.service = 'testService';
+		serverless.service.provider.compiledCloudFormationAliasTemplate = {};
+		awsAlias = new AWSAlias(serverless, options);
+
+		// Disable logging
+		logStub = sandbox.stub(serverless.cli, 'log');
+		logStub.returns();
+	});
+
+	afterEach(() => {
+		sandbox.restore();
+	});
+
+	describe('#aliasHandleSNSEvents()', () => {
+		it('should succeed with standard template', () => {
+			serverless.service.provider.compiledCloudFormationTemplate = require('../data/sls-stack-1.json');
+			serverless.service.provider.compiledCloudFormationAliasTemplate = require('../data/alias-stack-1.json');
+			return expect(awsAlias.aliasHandleSNSEvents({}, [], {})).to.be.fulfilled;
+		});
+
+		it('should move resources to alias stack', () => {
+			const snsStack = serverless.service.provider.compiledCloudFormationTemplate = require('../data/sns-stack.json');
+			const aliasStack = serverless.service.provider.compiledCloudFormationAliasTemplate = require('../data/alias-stack-1.json');
+			return expect(awsAlias.aliasHandleSNSEvents({}, [], {})).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(snsStack).to.not.have.property('SNSTopicSlstestprojecttopic'),
+				expect(snsStack).to.not.have.property('Testfct1LambdaPermissionSlstestprojecttopicSNS'),
+				expect(aliasStack).to.not.have.property('SNSTopicSlstestprojecttopic'),
+				expect(aliasStack).to.not.have.property('Testfct1LambdaPermissionSlstestprojecttopicSNS'),
+			]));
+		});
+
+		it('should replace function with alias reference', () => {
+			serverless.service.provider.compiledCloudFormationTemplate = require('../data/sns-stack.json');
+			const aliasStack = serverless.service.provider.compiledCloudFormationAliasTemplate = require('../data/alias-stack-1.json');
+			return expect(awsAlias.aliasHandleSNSEvents({}, [], {})).to.be.fulfilled
+			.then(() => BbPromise.all([
+				expect(aliasStack).to.not.have.property('SNSTopicSlstestprojecttopic')
+				.that.has.deep.property('Properties.Subscription[0].Endpoint')
+				.that.deep.equals({ Ref: 'Testfct1Alias' }),
+			]));
+		});
+	});
+});