diff --git a/packages/@aws-cdk/aws-iot/README.md b/packages/@aws-cdk/aws-iot/README.md index 7334c9d4e108e..bbde9aae8a21d 100644 --- a/packages/@aws-cdk/aws-iot/README.md +++ b/packages/@aws-cdk/aws-iot/README.md @@ -9,8 +9,47 @@ > > [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. + --- -This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. +AWS IoT Core lets you connect billions of IoT devices and route trillions of +messages to AWS services without managing infrastructure. + +## Installation + +Install the module: + +```console +$ npm i @aws-cdk/aws-iot +``` + +Import it into your code: + +```ts +import * as iot from '@aws-cdk/aws-iot'; +``` + +## `TopicRule` + +The `TopicRule` construct defined Rules that give your devices the ability to +interact with AWS services. + +For example, to define a rule: + +```ts +new iot.TopicRule(stack, 'MyTopicRule', { + topicRuleName: 'MyRuleName', // optional property + sql: iot.IotSql.fromStringAsVer20160323( + "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + ), +}); +``` diff --git a/packages/@aws-cdk/aws-iot/lib/index.ts b/packages/@aws-cdk/aws-iot/lib/index.ts index 4f78a6cf531e3..18b6f2e03aaeb 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 './iot-sql'; +export * from './topic-rule'; + // AWS::IoT CloudFormation Resources: export * from './iot.generated'; diff --git a/packages/@aws-cdk/aws-iot/lib/iot-sql.ts b/packages/@aws-cdk/aws-iot/lib/iot-sql.ts new file mode 100644 index 0000000000000..c673552743364 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/iot-sql.ts @@ -0,0 +1,75 @@ +import { Construct } from 'constructs'; + +/** + * The type returned from the `bind()` method in {@link IotSql}. + */ +export interface IotSqlConfig { + /** + * The version of the SQL rules engine to use when evaluating the rule. + */ + readonly awsIotSqlVersion: string; + + /** + * The SQL statement used to query the topic. + */ + readonly sql: string; +} + +/** + * Defines AWS IoT SQL. + */ +export abstract class IotSql { + /** + * Uses the original SQL version built on 2015-10-08. + * + * @param sql The actual SQL-like syntax query + * @returns Instance of IotSql + */ + public static fromStringAsVer20151008(sql: string): IotSql { + return new IotSqlImpl('2015-10-08', sql); + } + + /** + * Uses the SQL version built on 2016-03-23. + * + * @param sql The actual SQL-like syntax query + * @returns Instance of IotSql + */ + public static fromStringAsVer20160323(sql: string): IotSql { + return new IotSqlImpl('2016-03-23', sql); + } + + /** + * Uses the most recent beta SQL version. If you use this version, it might + * introduce breaking changes to your rules. + * + * @param sql The actual SQL-like syntax query + * @returns Instance of IotSql + */ + public static fromStringAsVerNewestUnstable(sql: string): IotSql { + return new IotSqlImpl('beta', sql); + } + + /** + * Returns the IoT SQL configuration. + */ + public abstract bind(scope: Construct): IotSqlConfig; +} + + +class IotSqlImpl extends IotSql { + constructor(private readonly version: string, private readonly sql: string) { + super(); + + if (sql === '') { + throw new Error('IoT SQL string cannot be empty'); + } + } + + bind(_scope: Construct): IotSqlConfig { + return { + awsIotSqlVersion: this.version, + sql: this.sql, + }; + } +} 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..17f121eb29ab3 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/lib/topic-rule.ts @@ -0,0 +1,106 @@ +import { ArnFormat, Resource, Stack, IResource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IotSql } from './iot-sql'; +import { CfnTopicRule } from './iot.generated'; + +/** + * Represents an AWS IoT Rule + */ +export interface ITopicRule extends IResource { + /** + * The value of the topic rule Amazon Resource Name (ARN), such as + * arn:aws:iot:us-east-2:123456789012:rule/rule_name + * + * @attribute + */ + readonly topicRuleArn: string; + + /** + * The name topic rule + * + * @attribute + */ + readonly topicRuleName: string; +} + +/** + * Properties for defining an AWS IoT Rule + */ +export interface TopicRuleProps { + /** + * The name of the rule. + * @default None + */ + readonly topicRuleName?: string; + + /** + * 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: IotSql; +} + +/** + * Defines an AWS IoT Rule in this stack. + */ +export class TopicRule extends Resource implements ITopicRule { + /** + * Import an existing AWS IoT Rule provided an ARN + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param topicRuleArn AWS IoT Rule ARN (i.e. arn:aws:iot:::rule/MyRule). + */ + public static fromTopicRuleArn(scope: Construct, id: string, topicRuleArn: string): ITopicRule { + const parts = Stack.of(scope).splitArn(topicRuleArn, ArnFormat.SLASH_RESOURCE_NAME); + if (!parts.resourceName) { + throw new Error(`Missing topic rule name in ARN: '${topicRuleArn}'`); + } + const resourceName = parts.resourceName; + + class Import extends Resource implements ITopicRule { + public readonly topicRuleArn = topicRuleArn; + public readonly topicRuleName = resourceName; + } + return new Import(scope, id, { + environmentFromArn: topicRuleArn, + }); + } + + /** + * Arn of this rule + * @attribute + */ + public readonly topicRuleArn: string; + + /** + * Name of this rule + * @attribute + */ + public readonly topicRuleName: string; + + constructor(scope: Construct, id: string, props: TopicRuleProps) { + super(scope, id, { + physicalName: props.topicRuleName, + }); + + const sqlConfig = props.sql.bind(this); + + const resource = new CfnTopicRule(this, 'Resource', { + ruleName: this.physicalName, + topicRulePayload: { + actions: [], + awsIotSqlVersion: sqlConfig.awsIotSqlVersion, + sql: sqlConfig.sql, + }, + }); + + this.topicRuleArn = this.getResourceArnAttribute(resource.attrArn, { + service: 'iot', + resource: 'rule', + resourceName: this.physicalName, + }); + this.topicRuleName = this.getResourceNameAttribute(resource.ref); + } +} diff --git a/packages/@aws-cdk/aws-iot/package.json b/packages/@aws-cdk/aws-iot/package.json index 9960168c78ec1..074ebd9c79435 100644 --- a/packages/@aws-cdk/aws-iot/package.json +++ b/packages/@aws-cdk/aws-iot/package.json @@ -74,9 +74,11 @@ "devDependencies": { "@aws-cdk/assertions": "0.0.0", "@aws-cdk/cdk-build-tools": "0.0.0", + "@aws-cdk/cdk-integ-tools": "0.0.0", "@aws-cdk/cfn2ts": "0.0.0", "@aws-cdk/pkglint": "0.0.0", - "@types/jest": "^26.0.24" + "@types/jest": "^26.0.24", + "jest": "^26.6.3" }, "dependencies": { "@aws-cdk/core": "0.0.0", @@ -91,7 +93,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/integ.topic-rule.expected.json b/packages/@aws-cdk/aws-iot/test/integ.topic-rule.expected.json new file mode 100644 index 0000000000000..cf4be2735229e --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/integ.topic-rule.expected.json @@ -0,0 +1,14 @@ +{ + "Resources": { + "TopicRule40A4EA44": { + "Type": "AWS::IoT::TopicRule", + "Properties": { + "TopicRulePayload": { + "Actions": [], + "AwsIotSqlVersion": "2015-10-08", + "Sql": "SELECT topic(2) as device_id FROM 'device/+/data'" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iot/test/integ.topic-rule.ts b/packages/@aws-cdk/aws-iot/test/integ.topic-rule.ts new file mode 100644 index 0000000000000..a06edc3c3f5e1 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/integ.topic-rule.ts @@ -0,0 +1,18 @@ +/// !cdk-integ pragma:ignore-assets +import * as cdk from '@aws-cdk/core'; +import * as iot from '../lib'; + +const app = new cdk.App(); + +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + new iot.TopicRule(this, 'TopicRule', { + sql: iot.IotSql.fromStringAsVer20151008("SELECT topic(2) as device_id FROM 'device/+/data'"), + }); + } +} + +new TestStack(app, 'test-stack'); +app.synth(); diff --git a/packages/@aws-cdk/aws-iot/test/iot.test.ts b/packages/@aws-cdk/aws-iot/test/iot.test.ts deleted file mode 100644 index 465c7bdea0693..0000000000000 --- a/packages/@aws-cdk/aws-iot/test/iot.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assertions'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -}); 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..1dec8c3065a86 --- /dev/null +++ b/packages/@aws-cdk/aws-iot/test/topic-rule.test.ts @@ -0,0 +1,124 @@ +import { Template } from '@aws-cdk/assertions'; +import * as cdk from '@aws-cdk/core'; +import * as iot from '../lib'; + +test('Default property', () => { + const stack = new cdk.Stack(); + + new iot.TopicRule(stack, 'MyTopicRule', { + sql: iot.IotSql.fromStringAsVer20151008("SELECT topic(2) as device_id, temperature FROM 'device/+/data'"), + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + TopicRulePayload: { + Actions: [], + 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 iot.TopicRule(stack, 'MyTopicRule', { + sql: iot.IotSql.fromStringAsVer20151008("SELECT topic(2) as device_id, temperature FROM 'device/+/data'"), + }); + + new cdk.CfnResource(stack, 'Res', { + type: 'Test::Resource', + properties: { + TopicRuleName: rule.topicRuleName, + }, + }); + + Template.fromStack(stack).hasResourceProperties('Test::Resource', { + TopicRuleName: { Ref: 'MyTopicRule4EC2091C' }, + }); +}); + +test('can get topic rule arn', () => { + const stack = new cdk.Stack(); + const rule = new iot.TopicRule(stack, 'MyTopicRule', { + sql: iot.IotSql.fromStringAsVer20151008("SELECT topic(2) as device_id, temperature FROM 'device/+/data'"), + }); + + new cdk.CfnResource(stack, 'Res', { + type: 'Test::Resource', + properties: { + TopicRuleArn: rule.topicRuleArn, + }, + }); + + Template.fromStack(stack).hasResourceProperties('Test::Resource', { + TopicRuleArn: { + 'Fn::GetAtt': ['MyTopicRule4EC2091C', 'Arn'], + }, + }); +}); + +test('can set physical name', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new iot.TopicRule(stack, 'MyTopicRule', { + topicRuleName: 'PhysicalName', + sql: iot.IotSql.fromStringAsVer20151008("SELECT topic(2) as device_id, temperature FROM 'device/+/data'"), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + RuleName: 'PhysicalName', + }); +}); + +test.each([ + ['fromStringAsVer20151008', iot.IotSql.fromStringAsVer20151008, '2015-10-08'], + ['fromStringAsVer20160323', iot.IotSql.fromStringAsVer20160323, '2016-03-23'], + ['fromStringAsVerNewestUnstable', iot.IotSql.fromStringAsVerNewestUnstable, 'beta'], +])('can set sql with using %s', (_, factoryMethod, version) => { + const stack = new cdk.Stack(); + + new iot.TopicRule(stack, 'MyTopicRule', { + sql: factoryMethod("SELECT topic(2) as device_id, temperature FROM 'device/+/data'"), + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IoT::TopicRule', { + TopicRulePayload: { + AwsIotSqlVersion: version, + Sql: "SELECT topic(2) as device_id, temperature FROM 'device/+/data'", + }, + }); +}); + +test.each([ + ['fromStringAsVer20151008', iot.IotSql.fromStringAsVer20151008], + ['fromStringAsVer20160323', iot.IotSql.fromStringAsVer20160323], + ['fromStringAsVerNewestUnstable', iot.IotSql.fromStringAsVerNewestUnstable], +])('Using %s fails when setting empty sql', (_, factoryMethod) => { + expect(() => { + factoryMethod(''); + }).toThrow('IoT SQL string cannot be empty'); +}); + +test('can import a TopicRule by ARN', () => { + const stack = new cdk.Stack(); + + const topicRuleArn = 'arn:aws:iot:ap-northeast-1:123456789012:rule/my-rule-name'; + + const topicRule = iot.TopicRule.fromTopicRuleArn(stack, 'TopicRuleFromArn', topicRuleArn); + + expect(topicRule).toMatchObject({ + topicRuleArn, + topicRuleName: 'my-rule-name', + }); +}); + +test('fails importing a TopicRule by ARN if the ARN is missing the name of the TopicRule', () => { + const stack = new cdk.Stack(); + + const topicRuleArn = 'arn:aws:iot:ap-northeast-1:123456789012:rule/'; + + expect(() => { + iot.TopicRule.fromTopicRuleArn(stack, 'TopicRuleFromArn', topicRuleArn); + }).toThrow("Missing topic rule name in ARN: 'arn:aws:iot:ap-northeast-1:123456789012:rule/'"); +});