From d0960f181e5f66daa1eb53be2190b7e62bd66030 Mon Sep 17 00:00:00 2001 From: Tatsuya Yamamoto Date: Sat, 22 Jan 2022 10:55:09 +0900 Subject: [PATCH] feat(iotevents): add DetectorModel L2 Construct (#18049) This PR is proposed by #17711. The first step of the roadmap in #17711 is implemented in this PR. > 1. implement DetectorModel and State with only required properties > - It will not be able to have multiple states yet. If this PR is merged, the simplest detector model can be created as following: ![image](https://user-images.githubusercontent.com/11013683/146365658-248bba67-743c-4ba3-a195-56223146525f.png) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-iotevents/README.md | 23 +- .../aws-iotevents/lib/detector-model.ts | 82 +++++ packages/@aws-cdk/aws-iotevents/lib/event.ts | 18 + .../@aws-cdk/aws-iotevents/lib/expression.ts | 75 +++++ packages/@aws-cdk/aws-iotevents/lib/index.ts | 4 + packages/@aws-cdk/aws-iotevents/lib/state.ts | 65 ++++ packages/@aws-cdk/aws-iotevents/package.json | 2 + .../aws-iotevents/test/detector-model.test.ts | 311 ++++++++++++++++++ .../test/integ.detector-model.expected.json | 60 ++++ .../test/integ.detector-model.ts | 27 +- 10 files changed, 659 insertions(+), 8 deletions(-) create mode 100644 packages/@aws-cdk/aws-iotevents/lib/detector-model.ts create mode 100644 packages/@aws-cdk/aws-iotevents/lib/event.ts create mode 100644 packages/@aws-cdk/aws-iotevents/lib/expression.ts create mode 100644 packages/@aws-cdk/aws-iotevents/lib/state.ts create mode 100644 packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts diff --git a/packages/@aws-cdk/aws-iotevents/README.md b/packages/@aws-cdk/aws-iotevents/README.md index 6dc6a681636cc..fe071d7baecc6 100644 --- a/packages/@aws-cdk/aws-iotevents/README.md +++ b/packages/@aws-cdk/aws-iotevents/README.md @@ -40,15 +40,30 @@ Import it into your code: import * as iotevents from '@aws-cdk/aws-iotevents'; ``` -## `Input` +## `DetectorModel` -Add an AWS IoT Events input to your stack: +The following example creates an AWS IoT Events detector model to your stack. +The detector model need a reference to at least one AWS IoT Events input. +AWS IoT Events inputs enable the detector to get MQTT payload values from IoT Core rules. ```ts import * as iotevents from '@aws-cdk/aws-iotevents'; -new iotevents.Input(this, 'MyInput', { - inputName: 'my_input', +const input = new iotevents.Input(this, 'MyInput', { + inputName: 'my_input', // optional attributeJsonPaths: ['payload.temperature'], }); + +const onlineState = new iotevents.State({ + stateName: 'online', + onEnter: [{ + eventName: 'test-event', + condition: iotevents.Expression.currentInput(input), + }], +}); + +new iotevents.DetectorModel(this, 'MyDetectorModel', { + detectorModelName: 'test-detector-model', // optional + initialState: onlineState, +}); ``` diff --git a/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts b/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts new file mode 100644 index 0000000000000..2a5d270fb0cde --- /dev/null +++ b/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts @@ -0,0 +1,82 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { Resource, IResource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnDetectorModel } from './iotevents.generated'; +import { State } from './state'; + +/** + * Represents an AWS IoT Events detector model + */ +export interface IDetectorModel extends IResource { + /** + * The name of the detector model. + * + * @attribute + */ + readonly detectorModelName: string; +} + +/** + * Properties for defining an AWS IoT Events detector model + */ +export interface DetectorModelProps { + /** + * The name of the detector model. + * + * @default - CloudFormation will generate a unique name of the detector model + */ + readonly detectorModelName?: string; + + /** + * The state that is entered at the creation of each detector. + */ + readonly initialState: State; + + /** + * The role that grants permission to AWS IoT Events to perform its operations. + * + * @default - a role will be created with default permissions + */ + readonly role?: iam.IRole; +} + +/** + * Defines an AWS IoT Events detector model in this stack. + */ +export class DetectorModel extends Resource implements IDetectorModel { + /** + * Import an existing detector model. + */ + public static fromDetectorModelName(scope: Construct, id: string, detectorModelName: string): IDetectorModel { + return new class extends Resource implements IDetectorModel { + public readonly detectorModelName = detectorModelName; + }(scope, id); + } + + public readonly detectorModelName: string; + + constructor(scope: Construct, id: string, props: DetectorModelProps) { + super(scope, id, { + physicalName: props.detectorModelName, + }); + + if (!props.initialState._onEnterEventsHaveAtLeastOneCondition()) { + throw new Error('Detector Model must have at least one Input with a condition'); + } + + const role = props.role ?? new iam.Role(this, 'DetectorModelRole', { + assumedBy: new iam.ServicePrincipal('iotevents.amazonaws.com'), + }); + + const resource = new CfnDetectorModel(this, 'Resource', { + detectorModelName: this.physicalName, + detectorModelDefinition: { + initialStateName: props.initialState.stateName, + states: [props.initialState._toStateJson()], + }, + roleArn: role.roleArn, + }); + + this.detectorModelName = this.getResourceNameAttribute(resource.ref); + } +} diff --git a/packages/@aws-cdk/aws-iotevents/lib/event.ts b/packages/@aws-cdk/aws-iotevents/lib/event.ts new file mode 100644 index 0000000000000..610469db9c32c --- /dev/null +++ b/packages/@aws-cdk/aws-iotevents/lib/event.ts @@ -0,0 +1,18 @@ +import { Expression } from './expression'; + +/** + * Specifies the actions to be performed when the condition evaluates to TRUE. + */ +export interface Event { + /** + * The name of the event. + */ + readonly eventName: string; + + /** + * The Boolean expression that, when TRUE, causes the actions to be performed. + * + * @default - none (the actions are always executed) + */ + readonly condition?: Expression; +} diff --git a/packages/@aws-cdk/aws-iotevents/lib/expression.ts b/packages/@aws-cdk/aws-iotevents/lib/expression.ts new file mode 100644 index 0000000000000..27fdf069c1b9f --- /dev/null +++ b/packages/@aws-cdk/aws-iotevents/lib/expression.ts @@ -0,0 +1,75 @@ +import { IInput } from './input'; + +/** + * Expression for events in Detector Model state + * @see https://docs.aws.amazon.com/iotevents/latest/developerguide/iotevents-expressions.html + */ +export abstract class Expression { + /** + * Create a expression from the given string + */ + public static fromString(value: string): Expression { + return new StringExpression(value); + } + + /** + * Create a expression for function `currentInput()`. + * It is evaluated to true if the specified input message was received. + */ + public static currentInput(input: IInput): Expression { + return this.fromString(`currentInput("${input.inputName}")`); + } + + /** + * Create a expression for get an input attribute as `$input.TemperatureInput.temperatures[2]`. + */ + public static inputAttribute(input: IInput, path: string): Expression { + return this.fromString(`$input.${input.inputName}.${path}`); + } + + /** + * Create a expression for the Equal operator + */ + public static eq(left: Expression, right: Expression): Expression { + return new BinaryOperationExpression(left, '==', right); + } + + /** + * Create a expression for the AND operator + */ + public static and(left: Expression, right: Expression): Expression { + return new BinaryOperationExpression(left, '&&', right); + } + + constructor() { + } + + /** + * this is called to evaluate the expression + */ + public abstract evaluate(): string; +} + +class StringExpression extends Expression { + constructor(private readonly value: string) { + super(); + } + + public evaluate() { + return this.value; + } +} + +class BinaryOperationExpression extends Expression { + constructor( + private readonly left: Expression, + private readonly operator: string, + private readonly right: Expression, + ) { + super(); + } + + public evaluate() { + return `${this.left.evaluate()} ${this.operator} ${this.right.evaluate()}`; + } +} diff --git a/packages/@aws-cdk/aws-iotevents/lib/index.ts b/packages/@aws-cdk/aws-iotevents/lib/index.ts index 3851e30984391..24913635ebe50 100644 --- a/packages/@aws-cdk/aws-iotevents/lib/index.ts +++ b/packages/@aws-cdk/aws-iotevents/lib/index.ts @@ -1,4 +1,8 @@ +export * from './detector-model'; +export * from './event'; +export * from './expression'; export * from './input'; +export * from './state'; // AWS::IoTEvents CloudFormation Resources: export * from './iotevents.generated'; diff --git a/packages/@aws-cdk/aws-iotevents/lib/state.ts b/packages/@aws-cdk/aws-iotevents/lib/state.ts new file mode 100644 index 0000000000000..e16d911d60004 --- /dev/null +++ b/packages/@aws-cdk/aws-iotevents/lib/state.ts @@ -0,0 +1,65 @@ +import { Event } from './event'; +import { CfnDetectorModel } from './iotevents.generated'; + +/** + * Properties for defining a state of a detector + */ +export interface StateProps { + /** + * The name of the state. + */ + readonly stateName: string; + + /** + * Specifies the events on enter. the conditions of the events are evaluated when the state is entered. + * If the condition is `TRUE`, the actions of the event are performed. + * + * @default - events on enter will not be set + */ + readonly onEnter?: Event[]; +} + +/** + * Defines a state of a detector + */ +export class State { + /** + * The name of the state + */ + public readonly stateName: string; + + constructor(private readonly props: StateProps) { + this.stateName = props.stateName; + } + + /** + * Return the state property JSON + * + * @internal + */ + public _toStateJson(): CfnDetectorModel.StateProperty { + const { stateName, onEnter } = this.props; + return { + stateName, + onEnter: onEnter && { events: getEventJson(onEnter) }, + }; + } + + /** + * returns true if this state has at least one condition via events + * + * @internal + */ + public _onEnterEventsHaveAtLeastOneCondition(): boolean { + return this.props.onEnter?.some(event => event.condition) ?? false; + } +} + +function getEventJson(events: Event[]): CfnDetectorModel.EventProperty[] { + return events.map(e => { + return { + eventName: e.eventName, + condition: e.condition?.evaluate(), + }; + }); +} diff --git a/packages/@aws-cdk/aws-iotevents/package.json b/packages/@aws-cdk/aws-iotevents/package.json index 339b7d938a853..f9ac79e55395a 100644 --- a/packages/@aws-cdk/aws-iotevents/package.json +++ b/packages/@aws-cdk/aws-iotevents/package.json @@ -83,10 +83,12 @@ "jest": "^27.4.7" }, "dependencies": { + "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, "peerDependencies": { + "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, diff --git a/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts b/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts new file mode 100644 index 0000000000000..d6fbadd5baf9b --- /dev/null +++ b/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts @@ -0,0 +1,311 @@ +import { Match, Template } from '@aws-cdk/assertions'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import * as iotevents from '../lib'; + +let stack: cdk.Stack; +beforeEach(() => { + stack = new cdk.Stack(); +}); + +test('Default property', () => { + // WHEN + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.fromString('test-eventCondition'), + }], + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelDefinition: { + InitialStateName: 'test-state', + States: [{ + StateName: 'test-state', + OnEnter: { + Events: [{ + EventName: 'test-eventName', + Condition: 'test-eventCondition', + }], + }, + }], + }, + RoleArn: { + 'Fn::GetAtt': ['MyDetectorModelDetectorModelRoleF2FB4D88', 'Arn'], + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [{ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'iotevents.amazonaws.com' }, + }], + }, + }); +}); + +test('can get detector model name', () => { + // GIVEN + const detectorModel = new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.fromString('test-eventCondition'), + }], + }), + }); + + // WHEN + new cdk.CfnResource(stack, 'Res', { + type: 'Test::Resource', + properties: { + DetectorModelName: detectorModel.detectorModelName, + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('Test::Resource', { + DetectorModelName: { Ref: 'MyDetectorModel559C0B0E' }, + }); +}); + +test('can set physical name', () => { + // WHEN + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + detectorModelName: 'test-detector-model', + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.fromString('test-eventCondition'), + }], + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelName: 'test-detector-model', + }); +}); + +test('can set multiple events to State', () => { + // WHEN + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [ + { + eventName: 'test-eventName1', + condition: iotevents.Expression.fromString('test-eventCondition'), + }, + { + eventName: 'test-eventName2', + }, + ], + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelDefinition: { + States: [ + Match.objectLike({ + OnEnter: { + Events: [ + { + EventName: 'test-eventName1', + Condition: 'test-eventCondition', + }, + { + EventName: 'test-eventName2', + }, + ], + }, + }), + ], + }, + }); +}); + +test('can set role', () => { + // WHEN + const role = iam.Role.fromRoleArn(stack, 'test-role', 'arn:aws:iam::123456789012:role/ForTest'); + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + role, + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.fromString('test-eventCondition'), + }], + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + RoleArn: 'arn:aws:iam::123456789012:role/ForTest', + }); +}); + +test('can import a DetectorModel by detectorModelName', () => { + // WHEN + const detectorModelName = 'detector-model-name'; + const detectorModel = iotevents.DetectorModel.fromDetectorModelName(stack, 'ExistingDetectorModel', detectorModelName); + + // THEN + expect(detectorModel).toMatchObject({ + detectorModelName: detectorModelName, + }); +}); + +test('cannot create without condition', () => { + expect(() => { + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + }], + }), + }); + }).toThrow('Detector Model must have at least one Input with a condition'); +}); + +test('cannot create without event', () => { + expect(() => { + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + }), + }); + }).toThrow('Detector Model must have at least one Input with a condition'); +}); + +describe('Expression', () => { + test('currentInput', () => { + // WHEN + const input = iotevents.Input.fromInputName(stack, 'MyInput', 'test-input'); + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.currentInput(input), + }], + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelDefinition: { + States: [ + Match.objectLike({ + OnEnter: { + Events: [Match.objectLike({ + Condition: 'currentInput("test-input")', + })], + }, + }), + ], + }, + }); + }); + + test('inputAttribute', () => { + // WHEN + const input = iotevents.Input.fromInputName(stack, 'MyInput', 'test-input'); + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.inputAttribute(input, 'json.path'), + }], + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelDefinition: { + States: [ + Match.objectLike({ + OnEnter: { + Events: [Match.objectLike({ + Condition: '$input.test-input.json.path', + })], + }, + }), + ], + }, + }); + }); + + test('eq', () => { + // WHEN + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.eq( + iotevents.Expression.fromString('"aaa"'), + iotevents.Expression.fromString('"bbb"'), + ), + }], + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelDefinition: { + States: [ + Match.objectLike({ + OnEnter: { + Events: [Match.objectLike({ + Condition: '"aaa" == "bbb"', + })], + }, + }), + ], + }, + }); + }); + + test('eq', () => { + // WHEN + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.and( + iotevents.Expression.fromString('true'), + iotevents.Expression.fromString('false'), + ), + }], + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelDefinition: { + States: [ + Match.objectLike({ + OnEnter: { + Events: [Match.objectLike({ + Condition: 'true && false', + })], + }, + }), + ], + }, + }); + }); +}); diff --git a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.expected.json b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.expected.json index 1f5d452b5475d..f97d40bc6da25 100644 --- a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.expected.json +++ b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.expected.json @@ -12,6 +12,66 @@ }, "InputName": "test_input" } + }, + "MyDetectorModelDetectorModelRoleF2FB4D88": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iotevents.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyDetectorModel559C0B0E": { + "Type": "AWS::IoTEvents::DetectorModel", + "Properties": { + "DetectorModelDefinition": { + "InitialStateName": "online", + "States": [ + { + "OnEnter": { + "Events": [ + { + "Condition": { + "Fn::Join": [ + "", + [ + "currentInput(\"", + { + "Ref": "MyInput08947B23" + }, + "\") && $input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature == 31.5" + ] + ] + }, + "EventName": "test-event" + } + ] + }, + "StateName": "online" + } + ] + }, + "RoleArn": { + "Fn::GetAtt": [ + "MyDetectorModelDetectorModelRoleF2FB4D88", + "Arn" + ] + }, + "DetectorModelName": "test-detector-model" + } } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts index cb900c83a3f44..8eeef110d5b8a 100644 --- a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts +++ b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts @@ -1,18 +1,37 @@ import * as cdk from '@aws-cdk/core'; import * as iotevents 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 iotevents.Input(this, 'MyInput', { + const input = new iotevents.Input(this, 'MyInput', { inputName: 'test_input', attributeJsonPaths: ['payload.temperature'], }); + + const onlineState = new iotevents.State({ + stateName: 'online', + onEnter: [{ + eventName: 'test-event', + // meaning `condition: 'currentInput("test_input") && $input.test_input.payload.temperature == 31.5'` + condition: iotevents.Expression.and( + iotevents.Expression.currentInput(input), + iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('31.5'), + ), + ), + }], + }); + + new iotevents.DetectorModel(this, 'MyDetectorModel', { + detectorModelName: 'test-detector-model', + initialState: onlineState, + }); } } -new TestStack(app, 'test-stack'); +const app = new cdk.App(); +new TestStack(app, 'detector-model-test-stack'); app.synth();