Skip to content

Commit

Permalink
feat(iot-actions): add SNS publish action (#18839)
Browse files Browse the repository at this point in the history
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*
  • Loading branch information
AdamVD committed Feb 9, 2022
1 parent 9975ec8 commit 3a39f6b
Show file tree
Hide file tree
Showing 10 changed files with 333 additions and 0 deletions.
22 changes: 22 additions & 0 deletions packages/@aws-cdk/aws-iot-actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}),
],
});
```
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-iot-actions/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
75 changes: 75 additions & 0 deletions packages/@aws-cdk/aws-iot-actions/lib/sns-topic-action.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
};
}
}
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-iot-actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Stack verification steps:
* * aws sns subscribe --topic-arn "arn:aws:sns:<region>:<account>:test-stack-MyTopic86869434-10F6E3DMK3E5P" --protocol email --notification-endpoint <email-addr>
* * 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();
103 changes: 103 additions & 0 deletions packages/@aws-cdk/aws-iot-actions/test/sns/sns-topic-action.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
9 changes: 9 additions & 0 deletions packages/@aws-cdk/aws-sns/lib/topic-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
*
Expand Down
3 changes: 3 additions & 0 deletions packages/@aws-cdk/aws-sns/lib/topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;

Expand Down Expand Up @@ -110,5 +112,6 @@ export class Topic extends TopicBase {
resource: this.physicalName,
});
this.topicName = this.getResourceNameAttribute(resource.attrTopicName);
this.fifo = props.fifo || false;
}
}
Loading

0 comments on commit 3a39f6b

Please sign in to comment.