diff --git a/packages/@aws-cdk/aws-iot-actions/README.md b/packages/@aws-cdk/aws-iot-actions/README.md index 860643683efd1..088fda5f3e5b8 100644 --- a/packages/@aws-cdk/aws-iot-actions/README.md +++ b/packages/@aws-cdk/aws-iot-actions/README.md @@ -30,6 +30,7 @@ Currently supported are: - Put records to Kinesis Data stream - Put records to Kinesis Data Firehose stream - Send messages to SQS queues +- Publish messages on SNS topics ## Republish a message to another MQTT topic @@ -256,3 +257,24 @@ const topicRule = new iot.TopicRule(this, 'TopicRule', { ], }); ``` + +## Publish messages on an SNS topic + +The code snippet below creates and AWS IoT Rule that publishes messages to an SNS topic when it is triggered: + +```ts +import * as sns from '@aws-cdk/aws-sns'; + +const topic = new sns.Topic(this, 'MyTopic'); + +const topicRule = new iot.TopicRule(this, 'TopicRule', { + sql: iot.IotSql.fromStringAsVer20160323( + "SELECT topic(2) as device_id, year, month, day FROM 'device/+/data'", + ), + actions: [ + new actions.SnsTopicAction(topic, { + messageFormat: actions.SnsActionMessageFormat.JSON, // optional property, default is SnsActionMessageFormat.RAW + }), + ], +}); +``` diff --git a/packages/@aws-cdk/aws-iot-actions/lib/index.ts b/packages/@aws-cdk/aws-iot-actions/lib/index.ts index c3a7bb547b1c8..5c214f4143309 100644 --- a/packages/@aws-cdk/aws-iot-actions/lib/index.ts +++ b/packages/@aws-cdk/aws-iot-actions/lib/index.ts @@ -8,3 +8,4 @@ export * from './kinesis-put-record-action'; export * from './lambda-function-action'; export * from './s3-put-object-action'; export * from './sqs-queue-action'; +export * from './sns-topic-action'; diff --git a/packages/@aws-cdk/aws-iot-actions/lib/sns-topic-action.ts b/packages/@aws-cdk/aws-iot-actions/lib/sns-topic-action.ts new file mode 100644 index 0000000000000..701eeb0d8b39e --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/lib/sns-topic-action.ts @@ -0,0 +1,75 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import * as sns from '@aws-cdk/aws-sns'; +import { CommonActionProps } from '.'; +import { singletonActionRole } from './private/role'; + +/** + * SNS topic action message format options. + */ +export enum SnsActionMessageFormat { + /** + * RAW message format. + */ + RAW = 'RAW', + + /** + * JSON message format. + */ + JSON = 'JSON' +} + +/** + * Configuration options for the SNS topic action. + */ +export interface SnsTopicActionProps extends CommonActionProps { + /** + * The message format of the message to publish. + * + * SNS uses this setting to determine if the payload should be parsed and relevant platform-specific bits of the payload should be extracted. + * @see https://docs.aws.amazon.com/sns/latest/dg/sns-message-and-json-formats.html + * + * @default SnsActionMessageFormat.RAW + */ + readonly messageFormat?: SnsActionMessageFormat; +} + +/** + * The action to write the data from an MQTT message to an Amazon SNS topic. + * + * @see https://docs.aws.amazon.com/iot/latest/developerguide/sns-rule-action.html + */ +export class SnsTopicAction implements iot.IAction { + private readonly role?: iam.IRole; + private readonly topic: sns.ITopic; + private readonly messageFormat?: SnsActionMessageFormat; + + /** + * @param topic The Amazon SNS topic to publish data on. Must not be a FIFO topic. + * @param props Properties to configure the action. + */ + constructor(topic: sns.ITopic, props: SnsTopicActionProps = {}) { + if (topic.fifo) { + throw Error('IoT Rule actions cannot be used with FIFO SNS Topics, please pass a non-FIFO Topic instead'); + } + + this.topic = topic; + this.role = props.role; + this.messageFormat = props.messageFormat; + } + + bind(rule: iot.ITopicRule): iot.ActionConfig { + const role = this.role ?? singletonActionRole(rule); + this.topic.grantPublish(role); + + return { + configuration: { + sns: { + targetArn: this.topic.topicArn, + roleArn: role.roleArn, + messageFormat: this.messageFormat, + }, + }, + }; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/package.json b/packages/@aws-cdk/aws-iot-actions/package.json index 2b4268d0039ad..2567eea67ecda 100644 --- a/packages/@aws-cdk/aws-iot-actions/package.json +++ b/packages/@aws-cdk/aws-iot-actions/package.json @@ -95,6 +95,7 @@ "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", "@aws-cdk/core": "0.0.0", "case": "1.6.3", @@ -110,6 +111,7 @@ "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" diff --git a/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-action.expected.json b/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-action.expected.json new file mode 100644 index 0000000000000..0bf9706012c93 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-action.expected.json @@ -0,0 +1,71 @@ +{ + "Resources": { + "TopicRule40A4EA44": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Sns": { + "RoleArn": { + "Fn::GetAtt": [ + "TopicRuleTopicRuleActionRole246C4F77", + "Arn" + ] + }, + "TargetArn": { + "Ref": "MyTopic86869434" + } + } + } + ], + "AwsIotSqlVersion": "2016-03-23", + "Sql": "SELECT topic(2) as device_id, year, month, day FROM 'device/+/data'" + } + } + }, + "TopicRuleTopicRuleActionRole246C4F77": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "TopicRuleTopicRuleActionRoleDefaultPolicy99ADD687": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "MyTopic86869434" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "TopicRuleTopicRuleActionRoleDefaultPolicy99ADD687", + "Roles": [ + { + "Ref": "TopicRuleTopicRuleActionRole246C4F77" + } + ] + } + }, + "MyTopic86869434": { + "Type": "AWS::SNS::Topic" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-action.ts b/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-action.ts new file mode 100644 index 0000000000000..5243ab2effab4 --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sns/integ.sns-topic-action.ts @@ -0,0 +1,33 @@ +/** + * Stack verification steps: + * * aws sns subscribe --topic-arn "arn:aws:sns:::test-stack-MyTopic86869434-10F6E3DMK3E5P" --protocol email --notification-endpoint + * * confirm subscription from email + * * echo '{"message": "hello world"}' > testfile.txt + * * aws iot-data publish --topic device/mydevice/data --qos 1 --payload fileb://testfile.txt + * * verify that an email was sent from the SNS + * * rm testfile.txt + */ +/// !cdk-integ pragma:ignore-assets +import * as iot from '@aws-cdk/aws-iot'; +import * as sns from '@aws-cdk/aws-sns'; +import * as cdk from '@aws-cdk/core'; +import * as actions from '../../lib'; + +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const topicRule = new iot.TopicRule(this, 'TopicRule', { + sql: iot.IotSql.fromStringAsVer20160323( + "SELECT topic(2) as device_id, year, month, day FROM 'device/+/data'", + ), + }); + + const snsTopic = new sns.Topic(this, 'MyTopic'); + topicRule.addAction(new actions.SnsTopicAction(snsTopic)); + } +} + +const app = new cdk.App(); +new TestStack(app, 'sns-topic-action-test-stack'); +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot-actions/test/sns/sns-topic-action.test.ts b/packages/@aws-cdk/aws-iot-actions/test/sns/sns-topic-action.test.ts new file mode 100644 index 0000000000000..c949b5202c72c --- /dev/null +++ b/packages/@aws-cdk/aws-iot-actions/test/sns/sns-topic-action.test.ts @@ -0,0 +1,103 @@ +import { Match, Template } from '@aws-cdk/assertions'; +import * as iam from '@aws-cdk/aws-iam'; +import * as iot from '@aws-cdk/aws-iot'; +import * as sns from '@aws-cdk/aws-sns'; +import * as cdk from '@aws-cdk/core'; +import * as actions from '../../lib'; + +const SNS_TOPIC_ARN = 'arn:aws:sns::123456789012:test-topic'; + +let stack: cdk.Stack; +let topicRule: iot.TopicRule; +let snsTopic: sns.ITopic; + +beforeEach(() => { + stack = new cdk.Stack(); + topicRule = new iot.TopicRule(stack, 'MyTopicRule', { + sql: iot.IotSql.fromStringAsVer20160323("SELECT topic(2) as device_id FROM 'device/+/data'"), + }); + snsTopic = sns.Topic.fromTopicArn(stack, 'MySnsTopic', SNS_TOPIC_ARN); +}); + +test('Default SNS topic action', () => { + // WHEN + topicRule.addAction(new actions.SnsTopicAction(snsTopic)); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [{ + Sns: { + RoleArn: { 'Fn::GetAtt': ['MyTopicRuleTopicRuleActionRoleCE2D05DA', 'Arn'] }, + TargetArn: SNS_TOPIC_ARN, + }, + }], + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [{ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'iot.amazonaws.com' }, + }], + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Action: 'sns:Publish', + Effect: 'Allow', + Resource: SNS_TOPIC_ARN, + }], + }, + Roles: [{ Ref: 'MyTopicRuleTopicRuleActionRoleCE2D05DA' }], + }); +}); + +test('Can set messageFormat', () => { + // WHEN + topicRule.addAction(new actions.SnsTopicAction(snsTopic, { + messageFormat: actions.SnsActionMessageFormat.JSON, + })); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + Match.objectLike({ Sns: { MessageFormat: 'JSON' } }), + ], + }, + }); +}); + +test('Can set role', () => { + // GIVEN + const roleArn = 'arn:aws:iam::123456789012:role/testrole'; + const role = iam.Role.fromRoleArn(stack, 'MyRole', roleArn); + + // WHEN + topicRule.addAction(new actions.SnsTopicAction(snsTopic, { + role, + })); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + Match.objectLike({ Sns: { RoleArn: roleArn } }), + ], + }, + }); +}); + +test('Action with FIFO topic throws error', () => { + // GIVEN + const snsFifoTopic = sns.Topic.fromTopicArn(stack, 'MyFifoTopic', `${SNS_TOPIC_ARN}.fifo`); + + expect(() => { + topicRule.addAction(new actions.SnsTopicAction(snsFifoTopic)); + }).toThrowError('IoT Rule actions cannot be used with FIFO SNS Topics, please pass a non-FIFO Topic instead'); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sns/lib/topic-base.ts b/packages/@aws-cdk/aws-sns/lib/topic-base.ts index b867670474177..70dff8b8572df 100644 --- a/packages/@aws-cdk/aws-sns/lib/topic-base.ts +++ b/packages/@aws-cdk/aws-sns/lib/topic-base.ts @@ -28,6 +28,13 @@ export interface ITopic extends IResource, notifications.INotificationRuleTarget */ readonly topicName: string; + /** + * Whether this topic is an Amazon SNS FIFO queue. If false, this is a standard topic. + * + * @attribute + */ + readonly fifo: boolean; + /** * Subscribe some endpoint to this topic */ @@ -56,6 +63,8 @@ export abstract class TopicBase extends Resource implements ITopic { public abstract readonly topicName: string; + public abstract readonly fifo: boolean; + /** * Controls automatic creation of policy objects. * diff --git a/packages/@aws-cdk/aws-sns/lib/topic.ts b/packages/@aws-cdk/aws-sns/lib/topic.ts index aa78dcfee6b80..0525e832181a2 100644 --- a/packages/@aws-cdk/aws-sns/lib/topic.ts +++ b/packages/@aws-cdk/aws-sns/lib/topic.ts @@ -64,6 +64,7 @@ export class Topic extends TopicBase { class Import extends TopicBase { public readonly topicArn = topicArn; public readonly topicName = Stack.of(scope).splitArn(topicArn, ArnFormat.NO_RESOURCE_NAME).resource; + public readonly fifo = this.topicName.endsWith('.fifo'); protected autoCreatePolicy: boolean = false; } @@ -72,6 +73,7 @@ export class Topic extends TopicBase { public readonly topicArn: string; public readonly topicName: string; + public readonly fifo: boolean; protected readonly autoCreatePolicy: boolean = true; @@ -110,5 +112,6 @@ export class Topic extends TopicBase { resource: this.physicalName, }); this.topicName = this.getResourceNameAttribute(resource.attrTopicName); + this.fifo = props.fifo || false; } } diff --git a/packages/@aws-cdk/aws-sns/test/sns.test.ts b/packages/@aws-cdk/aws-sns/test/sns.test.ts index 950aef6cbcef1..c39f6d709587b 100644 --- a/packages/@aws-cdk/aws-sns/test/sns.test.ts +++ b/packages/@aws-cdk/aws-sns/test/sns.test.ts @@ -343,9 +343,23 @@ describe('Topic', () => { // THEN expect(imported.topicName).toEqual('my_corporate_topic'); expect(imported.topicArn).toEqual('arn:aws:sns:*:123456789012:my_corporate_topic'); + expect(imported.fifo).toEqual(false); }); + test('fromTopicArn fifo', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const imported = sns.Topic.fromTopicArn(stack, 'Imported', 'arn:aws:sns:*:123456789012:mytopic.fifo'); + + // THEN + expect(imported.topicName).toEqual('mytopic.fifo'); + expect(imported.topicArn).toEqual('arn:aws:sns:*:123456789012:mytopic.fifo'); + expect(imported.fifo).toEqual(true); + }); + test('test metrics', () => { // GIVEN const stack = new cdk.Stack();