Skip to content

Commit 3a39f6b

Browse files
authored
feat(iot-actions): add SNS publish action (#18839)
First-time contributor 👋 fixes #17700 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 9975ec8 commit 3a39f6b

File tree

10 files changed

+333
-0
lines changed

10 files changed

+333
-0
lines changed

packages/@aws-cdk/aws-iot-actions/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Currently supported are:
3030
- Put records to Kinesis Data stream
3131
- Put records to Kinesis Data Firehose stream
3232
- Send messages to SQS queues
33+
- Publish messages on SNS topics
3334

3435
## Republish a message to another MQTT topic
3536

@@ -256,3 +257,24 @@ const topicRule = new iot.TopicRule(this, 'TopicRule', {
256257
],
257258
});
258259
```
260+
261+
## Publish messages on an SNS topic
262+
263+
The code snippet below creates and AWS IoT Rule that publishes messages to an SNS topic when it is triggered:
264+
265+
```ts
266+
import * as sns from '@aws-cdk/aws-sns';
267+
268+
const topic = new sns.Topic(this, 'MyTopic');
269+
270+
const topicRule = new iot.TopicRule(this, 'TopicRule', {
271+
sql: iot.IotSql.fromStringAsVer20160323(
272+
"SELECT topic(2) as device_id, year, month, day FROM 'device/+/data'",
273+
),
274+
actions: [
275+
new actions.SnsTopicAction(topic, {
276+
messageFormat: actions.SnsActionMessageFormat.JSON, // optional property, default is SnsActionMessageFormat.RAW
277+
}),
278+
],
279+
});
280+
```

packages/@aws-cdk/aws-iot-actions/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * from './kinesis-put-record-action';
88
export * from './lambda-function-action';
99
export * from './s3-put-object-action';
1010
export * from './sqs-queue-action';
11+
export * from './sns-topic-action';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as iam from '@aws-cdk/aws-iam';
2+
import * as iot from '@aws-cdk/aws-iot';
3+
import * as sns from '@aws-cdk/aws-sns';
4+
import { CommonActionProps } from '.';
5+
import { singletonActionRole } from './private/role';
6+
7+
/**
8+
* SNS topic action message format options.
9+
*/
10+
export enum SnsActionMessageFormat {
11+
/**
12+
* RAW message format.
13+
*/
14+
RAW = 'RAW',
15+
16+
/**
17+
* JSON message format.
18+
*/
19+
JSON = 'JSON'
20+
}
21+
22+
/**
23+
* Configuration options for the SNS topic action.
24+
*/
25+
export interface SnsTopicActionProps extends CommonActionProps {
26+
/**
27+
* The message format of the message to publish.
28+
*
29+
* SNS uses this setting to determine if the payload should be parsed and relevant platform-specific bits of the payload should be extracted.
30+
* @see https://docs.aws.amazon.com/sns/latest/dg/sns-message-and-json-formats.html
31+
*
32+
* @default SnsActionMessageFormat.RAW
33+
*/
34+
readonly messageFormat?: SnsActionMessageFormat;
35+
}
36+
37+
/**
38+
* The action to write the data from an MQTT message to an Amazon SNS topic.
39+
*
40+
* @see https://docs.aws.amazon.com/iot/latest/developerguide/sns-rule-action.html
41+
*/
42+
export class SnsTopicAction implements iot.IAction {
43+
private readonly role?: iam.IRole;
44+
private readonly topic: sns.ITopic;
45+
private readonly messageFormat?: SnsActionMessageFormat;
46+
47+
/**
48+
* @param topic The Amazon SNS topic to publish data on. Must not be a FIFO topic.
49+
* @param props Properties to configure the action.
50+
*/
51+
constructor(topic: sns.ITopic, props: SnsTopicActionProps = {}) {
52+
if (topic.fifo) {
53+
throw Error('IoT Rule actions cannot be used with FIFO SNS Topics, please pass a non-FIFO Topic instead');
54+
}
55+
56+
this.topic = topic;
57+
this.role = props.role;
58+
this.messageFormat = props.messageFormat;
59+
}
60+
61+
bind(rule: iot.ITopicRule): iot.ActionConfig {
62+
const role = this.role ?? singletonActionRole(rule);
63+
this.topic.grantPublish(role);
64+
65+
return {
66+
configuration: {
67+
sns: {
68+
targetArn: this.topic.topicArn,
69+
roleArn: role.roleArn,
70+
messageFormat: this.messageFormat,
71+
},
72+
},
73+
};
74+
}
75+
}

packages/@aws-cdk/aws-iot-actions/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"@aws-cdk/aws-lambda": "0.0.0",
9696
"@aws-cdk/aws-logs": "0.0.0",
9797
"@aws-cdk/aws-s3": "0.0.0",
98+
"@aws-cdk/aws-sns": "0.0.0",
9899
"@aws-cdk/aws-sqs": "0.0.0",
99100
"@aws-cdk/core": "0.0.0",
100101
"case": "1.6.3",
@@ -110,6 +111,7 @@
110111
"@aws-cdk/aws-lambda": "0.0.0",
111112
"@aws-cdk/aws-logs": "0.0.0",
112113
"@aws-cdk/aws-s3": "0.0.0",
114+
"@aws-cdk/aws-sns": "0.0.0",
113115
"@aws-cdk/aws-sqs": "0.0.0",
114116
"@aws-cdk/core": "0.0.0",
115117
"constructs": "^3.3.69"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"Resources": {
3+
"TopicRule40A4EA44": {
4+
"Type": "AWS::IoT::TopicRule",
5+
"Properties": {
6+
"TopicRulePayload": {
7+
"Actions": [
8+
{
9+
"Sns": {
10+
"RoleArn": {
11+
"Fn::GetAtt": [
12+
"TopicRuleTopicRuleActionRole246C4F77",
13+
"Arn"
14+
]
15+
},
16+
"TargetArn": {
17+
"Ref": "MyTopic86869434"
18+
}
19+
}
20+
}
21+
],
22+
"AwsIotSqlVersion": "2016-03-23",
23+
"Sql": "SELECT topic(2) as device_id, year, month, day FROM 'device/+/data'"
24+
}
25+
}
26+
},
27+
"TopicRuleTopicRuleActionRole246C4F77": {
28+
"Type": "AWS::IAM::Role",
29+
"Properties": {
30+
"AssumeRolePolicyDocument": {
31+
"Statement": [
32+
{
33+
"Action": "sts:AssumeRole",
34+
"Effect": "Allow",
35+
"Principal": {
36+
"Service": "iot.amazonaws.com"
37+
}
38+
}
39+
],
40+
"Version": "2012-10-17"
41+
}
42+
}
43+
},
44+
"TopicRuleTopicRuleActionRoleDefaultPolicy99ADD687": {
45+
"Type": "AWS::IAM::Policy",
46+
"Properties": {
47+
"PolicyDocument": {
48+
"Statement": [
49+
{
50+
"Action": "sns:Publish",
51+
"Effect": "Allow",
52+
"Resource": {
53+
"Ref": "MyTopic86869434"
54+
}
55+
}
56+
],
57+
"Version": "2012-10-17"
58+
},
59+
"PolicyName": "TopicRuleTopicRuleActionRoleDefaultPolicy99ADD687",
60+
"Roles": [
61+
{
62+
"Ref": "TopicRuleTopicRuleActionRole246C4F77"
63+
}
64+
]
65+
}
66+
},
67+
"MyTopic86869434": {
68+
"Type": "AWS::SNS::Topic"
69+
}
70+
}
71+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Stack verification steps:
3+
* * aws sns subscribe --topic-arn "arn:aws:sns:<region>:<account>:test-stack-MyTopic86869434-10F6E3DMK3E5P" --protocol email --notification-endpoint <email-addr>
4+
* * confirm subscription from email
5+
* * echo '{"message": "hello world"}' > testfile.txt
6+
* * aws iot-data publish --topic device/mydevice/data --qos 1 --payload fileb://testfile.txt
7+
* * verify that an email was sent from the SNS
8+
* * rm testfile.txt
9+
*/
10+
/// !cdk-integ pragma:ignore-assets
11+
import * as iot from '@aws-cdk/aws-iot';
12+
import * as sns from '@aws-cdk/aws-sns';
13+
import * as cdk from '@aws-cdk/core';
14+
import * as actions from '../../lib';
15+
16+
class TestStack extends cdk.Stack {
17+
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
18+
super(scope, id, props);
19+
20+
const topicRule = new iot.TopicRule(this, 'TopicRule', {
21+
sql: iot.IotSql.fromStringAsVer20160323(
22+
"SELECT topic(2) as device_id, year, month, day FROM 'device/+/data'",
23+
),
24+
});
25+
26+
const snsTopic = new sns.Topic(this, 'MyTopic');
27+
topicRule.addAction(new actions.SnsTopicAction(snsTopic));
28+
}
29+
}
30+
31+
const app = new cdk.App();
32+
new TestStack(app, 'sns-topic-action-test-stack');
33+
app.synth();
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Match, Template } from '@aws-cdk/assertions';
2+
import * as iam from '@aws-cdk/aws-iam';
3+
import * as iot from '@aws-cdk/aws-iot';
4+
import * as sns from '@aws-cdk/aws-sns';
5+
import * as cdk from '@aws-cdk/core';
6+
import * as actions from '../../lib';
7+
8+
const SNS_TOPIC_ARN = 'arn:aws:sns::123456789012:test-topic';
9+
10+
let stack: cdk.Stack;
11+
let topicRule: iot.TopicRule;
12+
let snsTopic: sns.ITopic;
13+
14+
beforeEach(() => {
15+
stack = new cdk.Stack();
16+
topicRule = new iot.TopicRule(stack, 'MyTopicRule', {
17+
sql: iot.IotSql.fromStringAsVer20160323("SELECT topic(2) as device_id FROM 'device/+/data'"),
18+
});
19+
snsTopic = sns.Topic.fromTopicArn(stack, 'MySnsTopic', SNS_TOPIC_ARN);
20+
});
21+
22+
test('Default SNS topic action', () => {
23+
// WHEN
24+
topicRule.addAction(new actions.SnsTopicAction(snsTopic));
25+
26+
// THEN
27+
Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', {
28+
TopicRulePayload: {
29+
Actions: [{
30+
Sns: {
31+
RoleArn: { 'Fn::GetAtt': ['MyTopicRuleTopicRuleActionRoleCE2D05DA', 'Arn'] },
32+
TargetArn: SNS_TOPIC_ARN,
33+
},
34+
}],
35+
},
36+
});
37+
38+
Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', {
39+
AssumeRolePolicyDocument: {
40+
Statement: [{
41+
Action: 'sts:AssumeRole',
42+
Effect: 'Allow',
43+
Principal: { Service: 'iot.amazonaws.com' },
44+
}],
45+
},
46+
});
47+
48+
Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', {
49+
PolicyDocument: {
50+
Statement: [{
51+
Action: 'sns:Publish',
52+
Effect: 'Allow',
53+
Resource: SNS_TOPIC_ARN,
54+
}],
55+
},
56+
Roles: [{ Ref: 'MyTopicRuleTopicRuleActionRoleCE2D05DA' }],
57+
});
58+
});
59+
60+
test('Can set messageFormat', () => {
61+
// WHEN
62+
topicRule.addAction(new actions.SnsTopicAction(snsTopic, {
63+
messageFormat: actions.SnsActionMessageFormat.JSON,
64+
}));
65+
66+
// THEN
67+
Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', {
68+
TopicRulePayload: {
69+
Actions: [
70+
Match.objectLike({ Sns: { MessageFormat: 'JSON' } }),
71+
],
72+
},
73+
});
74+
});
75+
76+
test('Can set role', () => {
77+
// GIVEN
78+
const roleArn = 'arn:aws:iam::123456789012:role/testrole';
79+
const role = iam.Role.fromRoleArn(stack, 'MyRole', roleArn);
80+
81+
// WHEN
82+
topicRule.addAction(new actions.SnsTopicAction(snsTopic, {
83+
role,
84+
}));
85+
86+
// THEN
87+
Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', {
88+
TopicRulePayload: {
89+
Actions: [
90+
Match.objectLike({ Sns: { RoleArn: roleArn } }),
91+
],
92+
},
93+
});
94+
});
95+
96+
test('Action with FIFO topic throws error', () => {
97+
// GIVEN
98+
const snsFifoTopic = sns.Topic.fromTopicArn(stack, 'MyFifoTopic', `${SNS_TOPIC_ARN}.fifo`);
99+
100+
expect(() => {
101+
topicRule.addAction(new actions.SnsTopicAction(snsFifoTopic));
102+
}).toThrowError('IoT Rule actions cannot be used with FIFO SNS Topics, please pass a non-FIFO Topic instead');
103+
});

packages/@aws-cdk/aws-sns/lib/topic-base.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export interface ITopic extends IResource, notifications.INotificationRuleTarget
2828
*/
2929
readonly topicName: string;
3030

31+
/**
32+
* Whether this topic is an Amazon SNS FIFO queue. If false, this is a standard topic.
33+
*
34+
* @attribute
35+
*/
36+
readonly fifo: boolean;
37+
3138
/**
3239
* Subscribe some endpoint to this topic
3340
*/
@@ -56,6 +63,8 @@ export abstract class TopicBase extends Resource implements ITopic {
5663

5764
public abstract readonly topicName: string;
5865

66+
public abstract readonly fifo: boolean;
67+
5968
/**
6069
* Controls automatic creation of policy objects.
6170
*

packages/@aws-cdk/aws-sns/lib/topic.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export class Topic extends TopicBase {
6464
class Import extends TopicBase {
6565
public readonly topicArn = topicArn;
6666
public readonly topicName = Stack.of(scope).splitArn(topicArn, ArnFormat.NO_RESOURCE_NAME).resource;
67+
public readonly fifo = this.topicName.endsWith('.fifo');
6768
protected autoCreatePolicy: boolean = false;
6869
}
6970

@@ -72,6 +73,7 @@ export class Topic extends TopicBase {
7273

7374
public readonly topicArn: string;
7475
public readonly topicName: string;
76+
public readonly fifo: boolean;
7577

7678
protected readonly autoCreatePolicy: boolean = true;
7779

@@ -110,5 +112,6 @@ export class Topic extends TopicBase {
110112
resource: this.physicalName,
111113
});
112114
this.topicName = this.getResourceNameAttribute(resource.attrTopicName);
115+
this.fifo = props.fifo || false;
113116
}
114117
}

0 commit comments

Comments
 (0)