Repository: serverless/serverless (JavaScript)
Date: 2018-08-19 22:31:37+00:00
Task type: Feature

Issue:
Feature Request: SNS Filter Policies
<!--
1. If you have a question and not a bug/feature request please ask it at http://forum.serverless.com
2. Please check if an issue already exists so there are no duplicates
3. Check out and follow our Guidelines: https://github.com/serverless/serverless/blob/master/CONTRIBUTING.md
4. Fill out the whole template so we have a good overview on the issue
5. Do not remove any section of the template. If something is not applicable leave it empty but leave it in the Issue
6. Please follow the template, otherwise we'll have to ask you to update it
-->

# This is a Feature Proposal

## Description

AWS previously released support for SNS [subscription filters](https://docs.aws.amazon.com/sns/latest/dg/message-filtering.html), but until today, CloudFormation has not supported it. See the CloudFormation [release notes](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/ReleaseHistory.html).

Now that CloudFormation officially supports it, Serverless should be able to implement it.

Similar or dependent issues:

## Additional Data

* ***Serverless Framework Version you're using***:
* ***Operating System***:
* ***Stack Trace***:
* ***Provider Error messages***:



Issue discussion:
They also released a [blog post](https://aws.amazon.com/blogs/compute/managing-amazon-sns-subscription-attributes-with-aws-cloudformation/) about the new CloudFormation features.

Solution patch:
diff --git a/docs/providers/aws/events/sns.md b/docs/providers/aws/events/sns.md
index 2dd1b2d091c..ccaf019cc00 100644
--- a/docs/providers/aws/events/sns.md
+++ b/docs/providers/aws/events/sns.md
@@ -95,3 +95,20 @@ functions:
           topicName: aggregate
           displayName: Data aggregation pipeline
 ```
+
+## Setting a filter policy
+
+This event definition creates an SNS topic which subscription uses a filter policy. The filter policy filters out messages that don't have attribute key `pet` with value `dog` or `cat`.
+
+```yml
+functions:
+  pets:
+    handler: pets.handler
+    events:
+      - sns:
+          topicName: pets
+          filterPolicy:
+            pet:
+              - dog
+              - cat
+```
diff --git a/lib/plugins/aws/package/compile/events/sns/index.js b/lib/plugins/aws/package/compile/events/sns/index.js
index cde37bd2f63..d07c343d98c 100644
--- a/lib/plugins/aws/package/compile/events/sns/index.js
+++ b/lib/plugins/aws/package/compile/events/sns/index.js
@@ -105,10 +105,10 @@ class AwsCompileSNSEvents {
               'Fn::GetAtt': [lambdaLogicalId, 'Arn'],
             };
 
-            if (topicArn) {
-              const subscriptionLogicalId = this.provider.naming
-                .getLambdaSnsSubscriptionLogicalId(functionName, topicName);
+            const subscriptionLogicalId = this.provider.naming
+              .getLambdaSnsSubscriptionLogicalId(functionName, topicName);
 
+            if (topicArn) {
               _.merge(template.Resources, {
                 [subscriptionLogicalId]: {
                   Type: 'AWS::SNS::Subscription',
@@ -116,6 +116,7 @@ class AwsCompileSNSEvents {
                     TopicArn: topicArn,
                     Protocol: 'lambda',
                     Endpoint: endpoint,
+                    FilterPolicy: event.sns.filterPolicy,
                   },
                 },
               });
@@ -134,6 +135,7 @@ class AwsCompileSNSEvents {
                   ],
                 ],
               };
+
               const topicLogicalId = this.provider.naming
                 .getTopicLogicalId(topicName);
 
@@ -142,21 +144,38 @@ class AwsCompileSNSEvents {
                 Protocol: 'lambda',
               };
 
-              if (topicLogicalId in template.Resources) {
-                template.Resources[topicLogicalId]
-                  .Properties.Subscription.push(subscription);
-              } else {
+              if (!(topicLogicalId in template.Resources)) {
                 _.merge(template.Resources, {
                   [topicLogicalId]: {
                     Type: 'AWS::SNS::Topic',
                     Properties: {
                       TopicName: topicName,
                       DisplayName: displayName,
-                      Subscription: [subscription],
                     },
                   },
                 });
               }
+
+              if (event.sns.filterPolicy) {
+                _.merge(template.Resources, {
+                  [subscriptionLogicalId]: {
+                    Type: 'AWS::SNS::Subscription',
+                    Properties:
+                      _.merge(subscription, {
+                        TopicArn: {
+                          Ref: topicLogicalId,
+                        },
+                        FilterPolicy: event.sns.filterPolicy,
+                      }),
+                  },
+                });
+              } else {
+                if (!template.Resources[topicLogicalId].Properties.Subscription) {
+                  template.Resources[topicLogicalId].Properties.Subscription = [];
+                }
+                template.Resources[topicLogicalId]
+                  .Properties.Subscription.push(subscription);
+              }
             }
 
             const lambdaPermissionLogicalId = this.provider.naming


Test patch:
diff --git a/lib/plugins/aws/package/compile/events/sns/index.test.js b/lib/plugins/aws/package/compile/events/sns/index.test.js
index edcbe67d0a8..504d34a961c 100644
--- a/lib/plugins/aws/package/compile/events/sns/index.test.js
+++ b/lib/plugins/aws/package/compile/events/sns/index.test.js
@@ -44,6 +44,9 @@ describe('AwsCompileSNSEvents', () => {
               sns: {
                 topicName: 'Topic 1',
                 displayName: 'Display name for topic 1',
+                filterPolicy: {
+                  pet: ['dog', 'cat'],
+                },
               },
             },
             {
@@ -67,6 +70,67 @@ describe('AwsCompileSNSEvents', () => {
       expect(awsCompileSNSEvents.serverless.service
         .provider.compiledCloudFormationTemplate.Resources.FirstLambdaPermissionTopic2SNS.Type
       ).to.equal('AWS::Lambda::Permission');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources.FirstSnsSubscriptionTopic1.Type
+      ).to.equal('AWS::SNS::Subscription');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate
+        .Resources.FirstSnsSubscriptionTopic1.Properties.FilterPolicy
+      ).to.eql({ pet: ['dog', 'cat'] });
+    });
+
+    it('should create corresponding resources when topic is defined in resources', () => {
+      awsCompileSNSEvents.serverless.service.functions = {
+        first: {
+          events: [
+            {
+              sns: {
+                topicName: 'Topic 1',
+                displayName: 'Display name for topic 1',
+                filterPolicy: {
+                  pet: ['dog', 'cat'],
+                },
+              },
+            },
+            {
+              sns: 'Topic 2',
+            },
+          ],
+        },
+      };
+
+      Object.assign(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources, {
+          SNSTopicTopic2: {
+            Type: 'AWS::SNS::Topic',
+            Properties: {
+              TopicName: 'Topic 2',
+              DisplayName: 'Display name for topic 2',
+            },
+          },
+        });
+
+      awsCompileSNSEvents.compileSNSEvents();
+
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources.SNSTopicTopic1.Type
+      ).to.equal('AWS::SNS::Topic');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources.SNSTopicTopic2.Type
+      ).to.equal('AWS::SNS::Topic');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources.FirstLambdaPermissionTopic1SNS.Type
+      ).to.equal('AWS::Lambda::Permission');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources.FirstLambdaPermissionTopic2SNS.Type
+      ).to.equal('AWS::Lambda::Permission');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources.FirstSnsSubscriptionTopic1.Type
+      ).to.equal('AWS::SNS::Subscription');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate
+        .Resources.FirstSnsSubscriptionTopic1.Properties.FilterPolicy
+      ).to.eql({ pet: ['dog', 'cat'] });
     });
 
     it('should create single SNS topic when the same topic is referenced repeatedly', () => {
@@ -94,10 +158,6 @@ describe('AwsCompileSNSEvents', () => {
       expect(awsCompileSNSEvents.serverless.service
         .provider.compiledCloudFormationTemplate.Resources.SNSTopicTopic1.Type
       ).to.equal('AWS::SNS::Topic');
-      expect(awsCompileSNSEvents.serverless.service
-        .provider.compiledCloudFormationTemplate.Resources.SNSTopicTopic1
-        .Properties.Subscription.length
-      ).to.equal(2);
       expect(awsCompileSNSEvents.serverless.service
         .provider.compiledCloudFormationTemplate.Resources.FirstLambdaPermissionTopic1SNS.Type
       ).to.equal('AWS::Lambda::Permission');
@@ -156,6 +216,10 @@ describe('AwsCompileSNSEvents', () => {
       expect(awsCompileSNSEvents.serverless.service
         .provider.compiledCloudFormationTemplate.Resources.FirstLambdaPermissionFooSNS.Type
       ).to.equal('AWS::Lambda::Permission');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate
+        .Resources.FirstSnsSubscriptionFoo.Properties.FilterPolicy
+      ).to.equal(undefined);
     });
 
     it('should create SNS topic when only arn is given as an object property', () => {
@@ -184,6 +248,22 @@ describe('AwsCompileSNSEvents', () => {
       ).to.equal('AWS::Lambda::Permission');
     });
 
+    it('should throw an error when the arn an object and the value is not a string', () => {
+      awsCompileSNSEvents.serverless.service.functions = {
+        first: {
+          events: [
+            {
+              sns: {
+                arn: 123,
+              },
+            },
+          ],
+        },
+      };
+
+      expect(() => { awsCompileSNSEvents.compileSNSEvents(); }).to.throw(Error);
+    });
+
     it('should create SNS topic when arn and topicName are given as object properties', () => {
       awsCompileSNSEvents.serverless.service.functions = {
         first: {
@@ -210,5 +290,39 @@ describe('AwsCompileSNSEvents', () => {
         .provider.compiledCloudFormationTemplate.Resources.FirstLambdaPermissionBarSNS.Type
       ).to.equal('AWS::Lambda::Permission');
     });
+
+    it('should create SNS topic when arn, topicName, and filterPolicy are given as object', () => {
+      awsCompileSNSEvents.serverless.service.functions = {
+        first: {
+          events: [
+            {
+              sns: {
+                topicName: 'bar',
+                arn: 'arn:aws:sns:region:accountid:bar',
+                filterPolicy: {
+                  pet: ['dog', 'cat'],
+                },
+              },
+            },
+          ],
+        },
+      };
+
+      awsCompileSNSEvents.compileSNSEvents();
+
+      expect(Object.keys(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources)
+      ).to.have.length(2);
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources.FirstSnsSubscriptionBar.Type
+      ).to.equal('AWS::SNS::Subscription');
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate
+        .Resources.FirstSnsSubscriptionBar.Properties.FilterPolicy
+      ).to.eql({ pet: ['dog', 'cat'] });
+      expect(awsCompileSNSEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources.FirstLambdaPermissionBarSNS.Type
+      ).to.equal('AWS::Lambda::Permission');
+    });
   });
 });