diff --git a/packages/@aws-cdk/aws-iot/README.md b/packages/@aws-cdk/aws-iot/README.md index 7334c9d4e108e..e21793b631929 100644 --- a/packages/@aws-cdk/aws-iot/README.md +++ b/packages/@aws-cdk/aws-iot/README.md @@ -9,6 +9,14 @@ > > [CFN Resources]: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + --- diff --git a/packages/@aws-cdk/aws-iot/lib/action.ts b/packages/@aws-cdk/aws-iot/lib/action.ts new file mode 100644 index 0000000000000..4b218e086327e --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/action.ts @@ -0,0 +1,120 @@ +import { IConstruct } from '@aws-cdk/core'; +import { CfnTopicRule } from './iot.generated'; + +/** + * An abstract action for TopicRule. + */ +export interface IAction { + /** + * Returns the topic rule action specification. + * + * @param rule The TopicRule that would trigger this action. + */ + bind(rule: IConstruct): ActionConfig; +} + +/** + * Properties for an topic rule action + */ +export interface ActionConfig { + /** + * An action to set state of an Amazon CloudWatch alarm. + * @default None + */ + readonly cloudwatchAlarm?: CfnTopicRule.CloudwatchAlarmActionProperty; + /** + * An action to send data to Amazon CloudWatch Logs. + * @default None + */ + readonly cloudwatchLogs?: CfnTopicRule.CloudwatchLogsActionProperty; + /** + * An action to capture an Amazon CloudWatch metric. + * @default None + */ + readonly cloudwatchMetric?: CfnTopicRule.CloudwatchMetricActionProperty; + /** + * An action to write all or part of an MQTT message to an Amazon DynamoDB table. + * @default None + */ + readonly dynamoDb?: CfnTopicRule.DynamoDBActionProperty; + /** + * An action to write all or part of an MQTT message to an Amazon DynamoDB table. + * @default None + */ + readonly dynamoDBv2?: CfnTopicRule.DynamoDBv2ActionProperty; + /** + * An action to write data from MQTT messages to an Amazon OpenSearch Service domain. + * @default None + */ + readonly elasticsearch?: CfnTopicRule.ElasticsearchActionProperty; + /** + * An action to send data from an MQTT message to an Amazon Kinesis Data Firehose stream. + * @default None + */ + readonly firehose?: CfnTopicRule.FirehoseActionProperty; + /** + * An action to send data from an MQTT message to a web application or service. + * @default None + */ + readonly http?: CfnTopicRule.HttpActionProperty; + /** + * An action to send data from an MQTT message to an AWS IoT Analytics channel. + * @default None + */ + readonly iotAnalytics?: CfnTopicRule.IotAnalyticsActionProperty; + /** + * An action to send data from an MQTT message to an AWS IoT Events input. + * @default None + */ + readonly iotEvents?: CfnTopicRule.IotEventsActionProperty; + /** + * An action to send data from an MQTT message to asset properties in AWS IoT SiteWise. + * @default None + */ + readonly iotSiteWise?: CfnTopicRule.IotSiteWiseActionProperty; + /** + * An action to sends messages directly to your Amazon MSK or self-managed Apache Kafka clusters for data analysis and visualization. + * @default None + */ + readonly kafka?: CfnTopicRule.KafkaActionProperty; + /** + * An action to write data from an MQTT message to Amazon Kinesis Data Streams. + * @default None + */ + readonly kinesis?: CfnTopicRule.KinesisActionProperty; + /** + * An action to invoke an AWS Lambda function, passing in an MQTT message. + * @default None + */ + readonly lambda?: CfnTopicRule.LambdaActionProperty; + /** + * An action to republish an MQTT message to another MQTT topic. + * @default None + */ + readonly republish?: CfnTopicRule.RepublishActionProperty; + /** + * An action to write the data from an MQTT message to an Amazon S3 bucket. + * @default None + */ + readonly s3?: CfnTopicRule.S3ActionProperty; + /** + * An action to send the data from an MQTT message as an Amazon SNS push notification. + * @default None + */ + readonly sns?: CfnTopicRule.SnsActionProperty; + /** + * An action to send data from an MQTT message to an Amazon SQS queue. + * @default None + */ + readonly sqs?: CfnTopicRule.SqsActionProperty; + /** + * An action to start an AWS Step Functions state machine. + * @default None + */ + readonly stepFunctions?: CfnTopicRule.StepFunctionsActionProperty; + /** + * An action to write attributes (measures) from an MQTT message into an Amazon Timestream table. + * @default None + */ + readonly timestream?: CfnTopicRule.TimestreamActionProperty; +} diff --git a/packages/@aws-cdk/aws-iot/lib/actions/index.ts b/packages/@aws-cdk/aws-iot/lib/actions/index.ts new file mode 100644 index 0000000000000..4f9d659abe512 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/actions/index.ts @@ -0,0 +1 @@ +export * from './republish-action'; diff --git a/packages/@aws-cdk/aws-iot/lib/actions/republish-action.ts b/packages/@aws-cdk/aws-iot/lib/actions/republish-action.ts new file mode 100644 index 0000000000000..3a9dc03388387 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/actions/republish-action.ts @@ -0,0 +1,79 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { IConstruct, Arn, Stack } from '@aws-cdk/core'; +import { IAction, ActionConfig } from '../'; +import { singletonActionRole } from './util'; + +/** + * Configuration properties of an action for republishing + */ +export interface RepublishActionProps { + /** + * The Quality of Service (QoS) level to use when republishing messages. + * + * @default QOS.LEVEL_0 + */ + readonly qos?: QOS; + /** + * The IAM role that allows AWS IoT to publish to the MQTT topic. + * + * @default a new role will be created + */ + readonly role?: iam.IRole; +} + +/** + * The Quality of Service (QoS) level to use when republishing messages. + * + * @see MQTT Quality of Service (QoS) options + */ +export enum QOS { + /** + * Sent zero or more times + */ + LEVEL_0, + /** + * Sent at least one time, and then repeatedly until a PUBACK response is received + */ + LEVEL_1, +} + +/** + * The action to republish an MQTT message to another MQTT topic. + */ +export class RepublishAction implements IAction { + private readonly qos?: number; + private readonly role?: iam.IRole; + + /** + * @param topic The MQTT topic to which to republish the message. + * @param props Optional properties to not use default + */ + constructor(private readonly topic: string, props: RepublishActionProps = {}) { + this.qos = props.qos; + this.role = props.role; + } + + bind(rule: IConstruct): ActionConfig { + const role = this.role ?? singletonActionRole(rule); + role.addToPrincipalPolicy(this.putEventStatement(rule)); + + return { + republish: { + qos: this.qos ?? QOS.LEVEL_0, + roleArn: role.roleArn, + topic: this.topic, + }, + }; + } + + private putEventStatement(scope: IConstruct) { + return new iam.PolicyStatement({ + actions: ['iot:Publish'], + resources: [ + Arn.format({ + service: 'iot', resource: 'topic', resourceName: this.topic, + }, Stack.of(scope)), + ], + }); + } +} diff --git a/packages/@aws-cdk/aws-iot/lib/actions/util.ts b/packages/@aws-cdk/aws-iot/lib/actions/util.ts new file mode 100644 index 0000000000000..614af44439767 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/actions/util.ts @@ -0,0 +1,25 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { IConstruct, PhysicalName } from '@aws-cdk/core'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + +/** + * Obtain the Role for the TopicRule + * + * If a role already exists, it will be returned. This ensures that if a rule have multiple + * actions, they will share a role. + * @internal + */ +export function singletonActionRole(scope: IConstruct): iam.IRole { + const id = 'TopicRuleActionRole'; + const existing = scope.node.tryFindChild(id) as iam.IRole; + if (existing) return existing; + + const role = new iam.Role(scope as Construct, id, { + roleName: PhysicalName.GENERATE_IF_NEEDED, + assumedBy: new iam.ServicePrincipal('iot.amazonaws.com'), + }); + return role; +} diff --git a/packages/@aws-cdk/aws-iot/lib/index.ts b/packages/@aws-cdk/aws-iot/lib/index.ts index 4f78a6cf531e3..636ea7980e376 100644 --- a/packages/@aws-cdk/aws-iot/lib/index.ts +++ b/packages/@aws-cdk/aws-iot/lib/index.ts @@ -1,2 +1,5 @@ +export * from './action'; +export * from './topic-rule'; + // AWS::IoT CloudFormation Resources: export * from './iot.generated'; diff --git a/packages/@aws-cdk/aws-iot/lib/topic-rule.ts b/packages/@aws-cdk/aws-iot/lib/topic-rule.ts new file mode 100644 index 0000000000000..f467ba3bffa5e --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/topic-rule.ts @@ -0,0 +1,152 @@ +import { Lazy, Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IAction } from './action'; +import { CfnTopicRule } from './iot.generated'; + +/** + * The version of the SQL rules engine to use when evaluating the rule. + * + * @see https://docs.aws.amazon.com/iot/latest/developerguide/iot-rule-sql-version.html + */ +export enum AwsIotSqlVersion { + /** + * 2015-10-08 – The original SQL version built on 2015-10-08. + */ + SQL_2015_10_08 = '2015-10-08', + /** + * 2016-03-23 – The SQL version built on 2016-03-23. + */ + SQL_2016_03_23 = '2016-03-23', + /** + * beta – The most recent beta SQL version. If you use this version, it might introduce breaking changes to your rules. + */ + SQL_BETA = 'beta', +} + +/** + * Properties for defining an AWS IoT Rule + */ +export interface TopicRuleProps { + /** + * The name of the rule. + * @default None + */ + readonly topicRuleName?: string; + /** + * The rule payload. + */ + readonly topicRulePayload: TopicRulePayloadProperty; +} + +/** + * Properties for defining details of an AWS IoT Rule + */ +export interface TopicRulePayloadProperty { + /** + * The actions associated with the rule. + * + * @default No actions will be perform + */ + readonly actions?: Array; + /** + * The version of the SQL rules engine to use when evaluating the rule. + * + * @default AwsIotSqlVersion.SQL_2015_10_08 + * @see https://docs.aws.amazon.com/iot/latest/developerguide/iot-create-rule.html + */ + readonly awsIotSqlVersion?: AwsIotSqlVersion; + /** + * A textual description of the rule. + * + * @default None + */ + readonly description?: string; + /** + * The action AWS IoT performs when it is unable to perform a rule's action. + * + * @default No action will be perform + */ + readonly errorAction?: IAction; + /** + * Specifies whether the rule is disabled. + * + * @default false + */ + readonly ruleDisabled?: boolean; + /** + * A simplified SQL syntax to filter messages received on an MQTT topic and push the data elsewhere. + * + * @see https://docs.aws.amazon.com/iot/latest/developerguide/iot-sql-reference.html + */ + readonly sql: string; +} + +/** + * Defines an AWS IoT Rule in this stack. + */ +export class TopicRule extends Resource { + /** + * Arn of this rule + * @attribute + */ + public readonly topicRuleArn: string; + /** + * Name of this rule + * @attribute + */ + public readonly topicRuleName: string; + + private readonly actions = new Array(); + + constructor(scope: Construct, id: string, props: TopicRuleProps) { + super(scope, id, { + physicalName: props.topicRuleName, + }); + + if (props.topicRulePayload.sql === '') { + throw new Error('\'topicRulePayload.sql\' cannot be empty.'); + } + + const resource = new CfnTopicRule(this, 'Resource', { + ruleName: this.physicalName, + topicRulePayload: { + actions: Lazy.any({ produce: () => this.actions }), + awsIotSqlVersion: props.topicRulePayload.awsIotSqlVersion ?? AwsIotSqlVersion.SQL_2015_10_08, + description: props.topicRulePayload.description, + errorAction: props.topicRulePayload.errorAction?.bind(this), + ruleDisabled: props.topicRulePayload.ruleDisabled ?? false, + sql: props.topicRulePayload.sql, + }, + }); + + this.topicRuleArn = this.getResourceArnAttribute(resource.attrArn, { + service: 'iot', + resource: 'rule', + resourceName: this.physicalName, + }); + this.topicRuleName = this.getResourceNameAttribute(resource.ref); + + props.topicRulePayload.actions?.forEach(action => { + this.addAction(action); + }); + } + + /** + * Add a action to the rule. + * + * @param action the action to associate with the rule. + */ + public addAction(action: IAction): void { + const actionConfig = action.bind(this); + + const keys = Object.keys(actionConfig); + if (keys.length === 0) { + throw new Error('Empty actions are not allowed. Please define one type of action'); + } + if (keys.length >= 2) { + throw new Error(`Each object in the actions list can only have one action defined. keys: ${keys}`); + } + + this.actions.push(actionConfig); + } +} diff --git a/packages/@aws-cdk/aws-iot/package.json b/packages/@aws-cdk/aws-iot/package.json index 64398bc0bcc25..a80a4010fab53 100644 --- a/packages/@aws-cdk/aws-iot/package.json +++ b/packages/@aws-cdk/aws-iot/package.json @@ -72,18 +72,23 @@ }, "license": "Apache-2.0", "devDependencies": { + "@aws-cdk/assertions": "0.0.0", + "@aws-cdk/assert-internal": "0.0.0", "@types/jest": "^26.0.24", "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", - "pkglint": "0.0.0", - "@aws-cdk/assertions": "0.0.0" + "jest": "^26.6.3", + "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, @@ -91,7 +96,7 @@ "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "experimental", - "maturity": "cfn-only", + "maturity": "experimental", "awscdkio": { "announce": false }, diff --git a/packages/@aws-cdk/aws-iot/test/actions/republish/integ.republish-action.expected.json b/packages/@aws-cdk/aws-iot/test/actions/republish/integ.republish-action.expected.json new file mode 100644 index 0000000000000..fe6a4d9348fd1 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/actions/republish/integ.republish-action.expected.json @@ -0,0 +1,85 @@ +{ + "Resources": { + "TopicRule40A4EA44": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [ + { + "Republish": { + "Qos": 0, + "RoleArn": { + "Fn::GetAtt": [ + "TopicRuleTopicRuleActionRole246C4F77", + "Arn" + ] + }, + "Topic": "test-topic" + } + } + ], + "AwsIotSqlVersion": "2015-10-08", + "RuleDisabled": false, + "Sql": "SELECT topic(2) as device_id 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": "iot:Publish", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iot:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":topic/test-topic" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "TopicRuleTopicRuleActionRoleDefaultPolicy99ADD687", + "Roles": [ + { + "Ref": "TopicRuleTopicRuleActionRole246C4F77" + } + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot/test/actions/republish/integ.republish-action.ts b/packages/@aws-cdk/aws-iot/test/actions/republish/integ.republish-action.ts new file mode 100644 index 0000000000000..5f1e0e2f87928 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/actions/republish/integ.republish-action.ts @@ -0,0 +1,24 @@ +/// !cdk-integ pragma:ignore-assets +import * as cdk from '@aws-cdk/core'; +import * as iot from '../../../lib'; +import * as actions from '../../../lib/actions'; + +const app = new cdk.App(); + +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', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id FROM 'device/+/data'", + }, + }); + topicRule.addAction(new actions.RepublishAction( + 'test-topic', + )); + } +} + +new TestStack(app, 'test-stack'); +app.synth(); diff --git a/packages/@aws-cdk/aws-iot/test/actions/republish/republish-action.test.ts b/packages/@aws-cdk/aws-iot/test/actions/republish/republish-action.test.ts new file mode 100644 index 0000000000000..2348ef7e60c67 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/actions/republish/republish-action.test.ts @@ -0,0 +1,214 @@ +import '@aws-cdk/assert-internal/jest'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { TopicRule } from '../../../lib'; +import { QOS, RepublishAction } from '../../../lib/actions'; + +test('Default republish action', () => { + const stack = new cdk.Stack(); + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { sql: "SELECT topic(2) as device_id FROM 'device/+/data'" }, + }); + + const action = new RepublishAction('test-topic'); + topicRule.addAction(action); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Republish: { + Qos: 0, + RoleArn: { + 'Fn::GetAtt': [ + 'MyTopicRuleTopicRuleActionRoleCE2D05DA', + 'Arn', + ], + }, + Topic: 'test-topic', + }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id FROM 'device/+/data'", + }, + }); +}); + +test('Default role that assumed by iot.amazonaws.com', () => { + const stack = new cdk.Stack(); + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { sql: "SELECT topic(2) as device_id FROM 'device/+/data'" }, + }); + + const action = new RepublishAction('test-topic'); + topicRule.addAction(action); + + expect(stack).toHaveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'iot.amazonaws.com', + }, + }, + ], + Version: '2012-10-17', + }, + }); +}); + +test('Default policy for republishing', () => { + const stack = new cdk.Stack(); + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { sql: "SELECT topic(2) as device_id FROM 'device/+/data'" }, + }); + + const action = new RepublishAction('test-topic'); + topicRule.addAction(action); + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'iot:Publish', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iot:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':topic/test-topic', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'MyTopicRuleTopicRuleActionRoleDefaultPolicy54A701F7', + Roles: [ + { Ref: 'MyTopicRuleTopicRuleActionRoleCE2D05DA' }, + ], + }); +}); + +test('Can set qos', () => { + const stack = new cdk.Stack(); + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { sql: "SELECT topic(2) as device_id FROM 'device/+/data'" }, + }); + + const action = new RepublishAction('test-topic', { + qos: QOS.LEVEL_1, + }); + topicRule.addAction(action); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Republish: { + Qos: 1, + RoleArn: { + 'Fn::GetAtt': [ + 'MyTopicRuleTopicRuleActionRoleCE2D05DA', + 'Arn', + ], + }, + Topic: 'test-topic', + }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id FROM 'device/+/data'", + }, + }); +}); + +test('Can set role', () => { + const stack = new cdk.Stack(); + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { sql: "SELECT topic(2) as device_id FROM 'device/+/data'" }, + }); + + const role = new iam.Role(stack, 'MyRole', { + assumedBy: new iam.ServicePrincipal('iot.amazonaws.com'), + }); + const action = new RepublishAction('test-topic', { role }); + topicRule.addAction(action); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Republish: { + Qos: 0, + RoleArn: { + 'Fn::GetAtt': [ + 'MyRoleF48FFE04', + 'Arn', + ], + }, + Topic: 'test-topic', + }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id FROM 'device/+/data'", + }, + }); +}); + +test('Add role to a policy for republishing', () => { + const stack = new cdk.Stack(); + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { sql: "SELECT topic(2) as device_id FROM 'device/+/data'" }, + }); + + const role = new iam.Role(stack, 'MyRole', { + assumedBy: new iam.ServicePrincipal('iot.amazonaws.com'), + }); + const action = new RepublishAction('test-topic', { role }); + topicRule.addAction(action); + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'iot:Publish', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iot:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':topic/test-topic', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'MyRoleDefaultPolicyA36BE1DD', + Roles: [ + { Ref: 'MyRoleF48FFE04' }, + ], + }); +}); diff --git a/packages/@aws-cdk/aws-iot/test/topic-rule.test.ts b/packages/@aws-cdk/aws-iot/test/topic-rule.test.ts new file mode 100644 index 0000000000000..5f646dc922216 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/topic-rule.test.ts @@ -0,0 +1,302 @@ +import '@aws-cdk/assert-internal/jest'; +import * as cdk from '@aws-cdk/core'; +import { TopicRule, IAction, AwsIotSqlVersion } from '../lib'; + +test('Default property', () => { + const stack = new cdk.Stack(); + + new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + + expect(stack).toMatchTemplate({ + Resources: { + MyTopicRule4EC2091C: { + Type: 'AWS::IoT::TopicRule', + Properties: { + TopicRulePayload: { + Actions: [], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }, + }, + }, + }); +}); + +test('Can get topic rule name', () => { + const stack = new cdk.Stack(); + const rule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + + new cdk.CfnResource(stack, 'Res', { + type: 'Test::Resource', + properties: { + TopicRuleName: rule.topicRuleName, + }, + }); + + expect(stack).toHaveResource('Test::Resource', { + TopicRuleName: { Ref: 'MyTopicRule4EC2091C' }, + }); +}); + +test('Can get topic rule arn', () => { + const stack = new cdk.Stack(); + const rule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + + new cdk.CfnResource(stack, 'Res', { + type: 'Test::Resource', + properties: { + TopicRuleArn: rule.topicRuleArn, + }, + }); + + expect(stack).toHaveResource('Test::Resource', { + TopicRuleArn: { + 'Fn::GetAtt': ['MyTopicRule4EC2091C', 'Arn'], + }, + }); +}); + +test('Can set physical name', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new TopicRule(stack, 'MyTopicRule', { + topicRuleName: 'PhysicalName', + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + + // THEN + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + RuleName: 'PhysicalName', + }); +}); + +test('Can set awsIotSqlVersion', () => { + const stack = new cdk.Stack(); + + new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + awsIotSqlVersion: AwsIotSqlVersion.SQL_BETA, + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [], + AwsIotSqlVersion: 'beta', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); +}); + +test('Can set description', () => { + const stack = new cdk.Stack(); + + new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + description: 'test-description', + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [], + AwsIotSqlVersion: '2015-10-08', + Description: 'test-description', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); +}); + +test('Can set ruleDisabled', () => { + const stack = new cdk.Stack(); + + new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + ruleDisabled: true, + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: true, + Sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); +}); + +test('Can set actions', () => { + const stack = new cdk.Stack(); + + const action1: IAction = { + bind: () => ({ + http: { url: 'http://example.com' }, + }), + }; + const action2: IAction = { + bind: () => ({ + lambda: { functionArn: 'test-functionArn' }, + }), + }; + + new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + actions: [action1, action2], + }, + }); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Http: { Url: 'http://example.com' }, + }, + { + Lambda: { FunctionArn: 'test-functionArn' }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); +}); + +test('Can set errorAction', () => { + const stack = new cdk.Stack(); + + const action: IAction = { + bind: () => ({ + http: { url: 'http://example.com' }, + }), + }; + + new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + errorAction: action, + }, + }); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + ErrorAction: { + Http: { Url: 'http://example.com' }, + }, + Actions: [], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); +}); + +test('Can add actions', () => { + const stack = new cdk.Stack(); + + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + topicRule.addAction({ + bind: () => ({ + http: { url: 'http://example.com' }, + }), + }); + topicRule.addAction({ + bind: () => ({ + lambda: { functionArn: 'test-functionArn' }, + }), + }); + + expect(stack).toHaveResource('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [ + { + Http: { Url: 'http://example.com' }, + }, + { + Lambda: { FunctionArn: 'test-functionArn' }, + }, + ], + AwsIotSqlVersion: '2015-10-08', + RuleDisabled: false, + Sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); +}); + +test('Can not set empty query', () => { + const stack = new cdk.Stack(); + + expect(() => new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: '', + }, + })).toThrowError( + '\'topicRulePayload.sql\' cannot be empty.', + ); +}); + +test('Can not add actions have no action property', () => { + const stack = new cdk.Stack(); + + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + const emptyAction: IAction = { + bind: () => ({}), + }; + + expect(() => topicRule.addAction(emptyAction)).toThrowError( + 'Empty actions are not allowed. Please define one type of action', + ); +}); + +test('Can not add actions have multiple action properties', () => { + const stack = new cdk.Stack(); + + const topicRule = new TopicRule(stack, 'MyTopicRule', { + topicRulePayload: { + sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); + const multipleAction: IAction = { + bind: () => ({ + http: { url: 'http://example.com' }, + lambda: { functionArn: 'test-functionArn' }, + }), + }; + + expect(() => topicRule.addAction(multipleAction)).toThrowError( + 'Each object in the actions list can only have one action defined. keys: http,lambda', + ); +});