diff --git a/packages/@aws-cdk/aws-iotevents/README.md b/packages/@aws-cdk/aws-iotevents/README.md index 809bac071ef7d..7c641f9025833 100644 --- a/packages/@aws-cdk/aws-iotevents/README.md +++ b/packages/@aws-cdk/aws-iotevents/README.md @@ -40,65 +40,211 @@ Import it into your code: import * as iotevents from '@aws-cdk/aws-iotevents'; ``` -## `DetectorModel` +## Overview -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. +The following example is a minimal set of an AWS IoT Events detector model. +It has no feature but it maybe help you to understand overview. ```ts import * as iotevents from '@aws-cdk/aws-iotevents'; +// First, define the input of the detector model const input = new iotevents.Input(this, 'MyInput', { - inputName: 'my_input', // optional attributeJsonPaths: ['payload.deviceId', 'payload.temperature'], }); -const warmState = new iotevents.State({ +// Second, define states of the detector model. +// You can define multiple states and its transitions. +const state = new iotevents.State({ stateName: 'warm', onEnter: [{ - eventName: 'test-event', + eventName: 'onEnter', condition: iotevents.Expression.currentInput(input), }], }); -const coldState = new iotevents.State({ - stateName: 'cold', + +// Finally, define the detector model. +new iotevents.DetectorModel(this, 'MyDetectorModel', { + initialState: state, }); +``` -// transit to coldState when temperature is 10 -warmState.transitionTo(coldState, { +Each part is explained in detail below. + +## `Input` + +You can create `Input` as following. You can put messages to the Input with AWS IoT Core Topic Rule, AWS IoT Analytics and more. +For more information, see [the documentation](https://docs.aws.amazon.com/iotevents/latest/developerguide/iotevents-getting-started.html). + +```ts +import * as iotevents from '@aws-cdk/aws-iotevents'; + +const input = new iotevents.Input(this, 'MyInput', { + inputName: 'my_input', // optional + attributeJsonPaths: ['payload.deviceId', 'payload.temperature'], +}); +``` + +To grant permissions to put messages in the input, +you can use the `grantWrite()` method: + +```ts +import * as iam from '@aws-cdk/aws-iam'; +import * as iotevents from '@aws-cdk/aws-iotevents'; + +declare const grantable: iam.IGrantable; +const input = iotevents.Input.fromInputName(this, 'MyInput', 'my_input'); + +input.grantWrite(grantable); +``` + +## `State` + +You can create `State` as following. +In `onEnter` of a detector model's initial state, at least one reference of input via `condition` is needed. +And if the `condition` is evaluated to `TRUE`, the detector instance are created. +You can set the reference of input with `iotevents.Expression.currentInput()` or `iotevents.Expression.inputAttribute()` as following. +In other states, `onEnter` is optional. + +```ts +import * as iotevents from '@aws-cdk/aws-iotevents'; + +declare const input: iotevents.IInput; + +const initialState = new iotevents.State({ + stateName: 'MyState', + onEnter: [{ + eventName: 'onEnter', + condition: iotevents.Expression.currentInput(input), + }], +}); +``` + +You can set actions to the `onEnter` event. It is caused if `condition` is evaluated to `TRUE`. +If you omit `condition`, actions is caused on every enter events of the state. +For more information, see [supported actions](https://docs.aws.amazon.com/iotevents/latest/developerguide/iotevents-supported-actions.html). + +```ts +import * as iotevents from '@aws-cdk/aws-iotevents'; + +declare const input: iotevents.IInput; + +const setTemperatureAction = { + bind: () => ({ + configuration: { + setVariable: { variableName: 'temperature', value: temperatureAttr.evaluate() }, + }, + }), +}; + +const state = new iotevents.State({ + stateName: 'MyState', + onEnter: [{ // optional + eventName: 'onEnter', + actions: [setTemperatureAction], // optional + condition: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('10'), + ), // optional + }], +}); +``` + +Also you can use `onInput` and `onExit`. `onInput` is triggered when messages are put to the input +that is refered from the detector model. `onExit` is triggered when exiting this state. + +```ts +import * as iotevents from '@aws-cdk/aws-iotevents'; + +const state = new iotevents.State({ + stateName: 'warm', + onEnter: [{ // optional + eventName: 'onEnter', + }], + onInput: [{ // optional + eventName: 'onInput', + }], + onExit: [{ // optional + eventName: 'onExit', + }], +}); +``` + +You can set transitions of the states as following: + +```ts +import * as iotevents from '@aws-cdk/aws-iotevents'; + +declare const input: iotevents.IInput; +declare const action: iotevents.IAction; +declare const stateA: iotevents.State; +declare const stateB: iotevents.State; + +// transit from stateA to stateB when temperature is 10 +stateA.transitionTo(stateB, { eventName: 'to_coldState', // optional property, default by combining the names of the States + actions: [action], // optional, when: iotevents.Expression.eq( iotevents.Expression.inputAttribute(input, 'payload.temperature'), iotevents.Expression.fromString('10'), ), }); -// transit to warmState when temperature is 20 -coldState.transitionTo(warmState, { - when: iotevents.Expression.eq( - iotevents.Expression.inputAttribute(input, 'payload.temperature'), - iotevents.Expression.fromString('20'), - ), -}); +``` + +## `DetectorModel` + +You can create `DetectorModel` as following. + +```ts +import * as iotevents from '@aws-cdk/aws-iotevents'; + +declare const state: iotevents.State; new iotevents.DetectorModel(this, 'MyDetectorModel', { detectorModelName: 'test-detector-model', // optional description: 'test-detector-model-description', // optional property, default is none evaluationMethod: iotevents.EventEvaluation.SERIAL, // optional property, default is iotevents.EventEvaluation.BATCH detectorKey: 'payload.deviceId', // optional property, default is none and single detector instance will be created and all inputs will be routed to it - initialState: warmState, + initialState: state, }); ``` -To grant permissions to put messages in the input, -you can use the `grantWrite()` method: +## Examples + +The following example creates an AWS IoT Events detector model to your stack. +The State of this detector model transits according to the temperature. ```ts -import * as iam from '@aws-cdk/aws-iam'; import * as iotevents from '@aws-cdk/aws-iotevents'; -declare const grantable: iam.IGrantable; -const input = iotevents.Input.fromInputName(this, 'MyInput', 'my_input'); +const input = new iotevents.Input(this, 'MyInput', { + attributeJsonPaths: ['payload.deviceId', 'payload.temperature'], +}); -input.grantWrite(grantable); +const warmState = new iotevents.State({ + stateName: 'warm', + onEnter: [{ + eventName: 'onEnter', + condition: iotevents.Expression.currentInput(input), + }], +}); +const coldState = new iotevents.State({ + stateName: 'cold', +}); + +const temperatureEqual = (temperature: string) => + iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('10'), + ) + +// transit to coldState when temperature is 10 +warmState.transitionTo(coldState, { when: temperatureEqual('10') }); +// transit to warmState when temperature is 20 +coldState.transitionTo(warmState, { when: temperatureEqual('20') }); + +new iotevents.DetectorModel(this, 'MyDetectorModel', { + detectorKey: 'payload.deviceId', + initialState: warmState, +}); ``` diff --git a/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts b/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts index 35128bc4531e6..535bca3bafdbe 100644 --- a/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts +++ b/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts @@ -124,7 +124,7 @@ export class DetectorModel extends Resource implements IDetectorModel { key: props.detectorKey, detectorModelDefinition: { initialStateName: props.initialState.stateName, - states: props.initialState._collectStateJsons(new Set()), + states: props.initialState.bind(this), }, roleArn: role.roleArn, }); diff --git a/packages/@aws-cdk/aws-iotevents/lib/event.ts b/packages/@aws-cdk/aws-iotevents/lib/event.ts index 610469db9c32c..98d145dc2c110 100644 --- a/packages/@aws-cdk/aws-iotevents/lib/event.ts +++ b/packages/@aws-cdk/aws-iotevents/lib/event.ts @@ -1,3 +1,4 @@ +import { IAction } from './action'; import { Expression } from './expression'; /** @@ -15,4 +16,11 @@ export interface Event { * @default - none (the actions are always executed) */ readonly condition?: Expression; + + /** + * The actions to be performed. + * + * @default - none + */ + readonly actions?: IAction[]; } diff --git a/packages/@aws-cdk/aws-iotevents/lib/index.ts b/packages/@aws-cdk/aws-iotevents/lib/index.ts index 24913635ebe50..b949a47454c3a 100644 --- a/packages/@aws-cdk/aws-iotevents/lib/index.ts +++ b/packages/@aws-cdk/aws-iotevents/lib/index.ts @@ -1,3 +1,4 @@ +export * from './action'; export * from './detector-model'; export * from './event'; export * from './expression'; diff --git a/packages/@aws-cdk/aws-iotevents/lib/state.ts b/packages/@aws-cdk/aws-iotevents/lib/state.ts index 67ee6a32802ec..9cbff0dc2fe11 100644 --- a/packages/@aws-cdk/aws-iotevents/lib/state.ts +++ b/packages/@aws-cdk/aws-iotevents/lib/state.ts @@ -1,3 +1,5 @@ +import { IAction } from './action'; +import { IDetectorModel } from './detector-model'; import { Event } from './event'; import { Expression } from './expression'; import { CfnDetectorModel } from './iotevents.generated'; @@ -18,6 +20,13 @@ export interface TransitionOptions { * When this was evaluated to TRUE, the state transition and the actions are triggered. */ readonly when: Expression; + + /** + * The actions to be performed. + * + * @default - none + */ + readonly actions?: IAction[]; } /** @@ -34,6 +43,13 @@ interface TransitionEvent { */ readonly condition: Expression; + /** + * The actions to be performed. + * + * @default - none + */ + readonly actions?: IAction[]; + /** * The next state to transit to. When the resuld of condition expression is TRUE, the state is transited. */ @@ -50,12 +66,28 @@ export interface StateProps { readonly stateName: string; /** - * Specifies the events on enter. the conditions of the events are evaluated when the state is entered. + * Specifies the events on enter. the conditions of the events are evaluated when entering this state. * If the condition is `TRUE`, the actions of the event are performed. * * @default - events on enter will not be set */ readonly onEnter?: Event[]; + + /** + * Specifies the events on inputed. the conditions of the events are evaluated when an input is received. + * If the condition is `TRUE`, the actions of the event are performed. + * + * @default - events on inputed will not be set + */ + readonly onInput?: Event[]; + + /** + * Specifies the events on exit. the conditions of the events are evaluated when exiting this state. + * If the condition is `TRUE`, the actions of the event are performed. + * + * @default - events on exit will not be set + */ + readonly onExit?: Event[]; } /** @@ -90,28 +122,15 @@ export class State { eventName: options.eventName ?? `${this.stateName}_to_${targetState.stateName}`, nextState: targetState, condition: options.when, + actions: options.actions, }); } /** - * Collect states in dependency gragh that constructed by state transitions, - * and return the JSONs of the states. - * This function is called recursively and collect the states. - * - * @internal + * Return the JSONs of the states in dependency gragh that constructed by state transitions */ - public _collectStateJsons(collectedStates: Set): CfnDetectorModel.StateProperty[] { - if (collectedStates.has(this)) { - return []; - } - collectedStates.add(this); - - return [ - this.toStateJson(), - ...this.transitionEvents.flatMap(transitionEvent => { - return transitionEvent.nextState._collectStateJsons(collectedStates); - }), - ]; + public bind(detectorModel: IDetectorModel): CfnDetectorModel.StateProperty[] { + return this.collectStates(new Set()).map(state => state.toStateJson(detectorModel)); } /** @@ -123,26 +142,52 @@ export class State { return this.props.onEnter?.some(event => event.condition) ?? false; } - private toStateJson(): CfnDetectorModel.StateProperty { - const { onEnter } = this.props; + private collectStates(collectedStates: Set): State[] { + if (collectedStates.has(this)) { + return []; + } + collectedStates.add(this); + + return [this, ...this.transitionEvents.flatMap(transitionEvent => transitionEvent.nextState.collectStates(collectedStates))]; + } + + private toStateJson(detectorModel: IDetectorModel): CfnDetectorModel.StateProperty { + const { onEnter, onInput, onExit } = this.props; return { stateName: this.stateName, - onEnter: onEnter && { events: toEventsJson(onEnter) }, + onEnter: { + events: toEventsJson(detectorModel, onEnter), + }, onInput: { - transitionEvents: toTransitionEventsJson(this.transitionEvents), + events: toEventsJson(detectorModel, onInput), + transitionEvents: toTransitionEventsJson(detectorModel, this.transitionEvents), + }, + onExit: { + events: toEventsJson(detectorModel, onExit), }, }; } } -function toEventsJson(events: Event[]): CfnDetectorModel.EventProperty[] { +function toEventsJson( + detectorModel: IDetectorModel, + events?: Event[], +): CfnDetectorModel.EventProperty[] | undefined { + if (!events) { + return undefined; + } + return events.map(event => ({ eventName: event.eventName, condition: event.condition?.evaluate(), + actions: event.actions?.map(action => action.bind(detectorModel).configuration), })); } -function toTransitionEventsJson(transitionEvents: TransitionEvent[]): CfnDetectorModel.TransitionEventProperty[] | undefined { +function toTransitionEventsJson( + detectorModel: IDetectorModel, + transitionEvents: TransitionEvent[], +): CfnDetectorModel.TransitionEventProperty[] | undefined { if (transitionEvents.length === 0) { return undefined; } @@ -150,6 +195,7 @@ function toTransitionEventsJson(transitionEvents: TransitionEvent[]): CfnDetecto return transitionEvents.map(transitionEvent => ({ eventName: transitionEvent.eventName, condition: transitionEvent.condition.evaluate(), + actions: transitionEvent.actions?.map(action => action.bind(detectorModel).configuration), nextState: transitionEvent.nextState.stateName, })); } diff --git a/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts b/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts index c90a10cf34374..c781e1a15c09b 100644 --- a/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts +++ b/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts @@ -109,6 +109,7 @@ test('can set multiple events to State', () => { { eventName: 'test-eventName1', condition: iotevents.Expression.fromString('test-eventCondition'), + actions: [{ bind: () => ({ configuration: { setTimer: { timerName: 'test-timer' } } }) }], }, { eventName: 'test-eventName2', @@ -127,6 +128,7 @@ test('can set multiple events to State', () => { { EventName: 'test-eventName1', Condition: 'test-eventCondition', + Actions: [{ SetTimer: { TimerName: 'test-timer' } }], }, { EventName: 'test-eventName2', @@ -139,6 +141,29 @@ test('can set multiple events to State', () => { }); }); +test.each([ + ['onInput', { onInput: [{ eventName: 'test-eventName1' }] }, { OnInput: { Events: [{ EventName: 'test-eventName1' }] } }], + ['onExit', { onExit: [{ eventName: 'test-eventName1' }] }, { OnExit: { Events: [{ EventName: 'test-eventName1' }] } }], +])('can set %s to State', (_, events, expected) => { + // WHEN + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: new iotevents.State({ + stateName: 'test-state', + onEnter: [{ eventName: 'test-eventName1', condition: iotevents.Expression.currentInput(input) }], + ...events, + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelDefinition: { + States: [ + Match.objectLike(expected), + ], + }, + }); +}); + test('can set states with transitions', () => { // GIVEN const firstState = new iotevents.State({ @@ -162,6 +187,7 @@ test('can set states with transitions', () => { iotevents.Expression.inputAttribute(input, 'payload.temperature'), iotevents.Expression.fromString('12'), ), + actions: [{ bind: () => ({ configuration: { setTimer: { timerName: 'test-timer' } } }) }], }); // transition as 2nd -> 1st, make circular reference secondState.transitionTo(firstState, { @@ -194,6 +220,7 @@ test('can set states with transitions', () => { EventName: 'firstState_to_secondState', NextState: 'secondState', Condition: '$input.test-input.payload.temperature == 12', + Actions: [{ SetTimer: { TimerName: 'test-timer' } }], }], }, }, 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 888869a41e68e..5556a3cee387f 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 @@ -43,6 +43,25 @@ "OnEnter": { "Events": [ { + "Actions": [ + { + "SetVariable": { + "Value": { + "Fn::Join": [ + "", + [ + "$input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature" + ] + ] + }, + "VariableName": "temperature" + } + } + ], "Condition": { "Fn::Join": [ "", @@ -59,13 +78,106 @@ ] ] }, - "EventName": "test-event" + "EventName": "test-enter-event" + } + ] + }, + "OnExit": { + "Events": [ + { + "Actions": [ + { + "SetVariable": { + "Value": { + "Fn::Join": [ + "", + [ + "$input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature" + ] + ] + }, + "VariableName": "temperature" + } + } + ], + "Condition": { + "Fn::Join": [ + "", + [ + "$input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature == 31.7" + ] + ] + }, + "EventName": "test-exit-event" } ] }, "OnInput": { + "Events": [ + { + "Actions": [ + { + "SetVariable": { + "Value": { + "Fn::Join": [ + "", + [ + "$input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature" + ] + ] + }, + "VariableName": "temperature" + } + } + ], + "Condition": { + "Fn::Join": [ + "", + [ + "$input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature == 31.6" + ] + ] + }, + "EventName": "test-input-event" + } + ], "TransitionEvents": [ { + "Actions": [ + { + "SetVariable": { + "Value": { + "Fn::Join": [ + "", + [ + "$input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature" + ] + ] + }, + "VariableName": "temperature" + } + } + ], "Condition": { "Fn::Join": [ "", @@ -86,6 +198,8 @@ "StateName": "online" }, { + "OnEnter": {}, + "OnExit": {}, "OnInput": { "TransitionEvents": [ { 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 5f6d2839f3a93..62305f55801ba 100644 --- a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts +++ b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts @@ -1,3 +1,11 @@ +/** + * Stack verification steps: + * * put a message + * * aws iotevents-data batch-put-message --messages=messageId=(date | md5),inputName=test_input,payload=(echo '{"payload":{"temperature":31.9,"deviceId":"000"}}' | base64) + * * describe the detector + * * aws iotevents-data describe-detector --detector-model-name test-detector-model --key-value=000 + * * verify `stateName` and `variables` of the detector + */ import * as cdk from '@aws-cdk/core'; import * as iotevents from '../lib'; @@ -10,38 +18,46 @@ class TestStack extends cdk.Stack { attributeJsonPaths: ['payload.deviceId', 'payload.temperature'], }); + const inputted = iotevents.Expression.currentInput(input); + const temperatureAttr = iotevents.Expression.inputAttribute(input, 'payload.temperature'); + const temperatureEqual = (temperature: string) => iotevents.Expression.eq( + temperatureAttr, + iotevents.Expression.fromString(temperature), + ); + + const setTemperatureAction = { + bind: () => ({ + configuration: { + setVariable: { variableName: 'temperature', value: temperatureAttr.evaluate() }, + }, + }), + }; + const onlineState = new iotevents.State({ stateName: 'online', onEnter: [{ - eventName: 'test-event', + eventName: 'test-enter-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'), - ), - ), + condition: iotevents.Expression.and(inputted, temperatureEqual('31.5')), + actions: [setTemperatureAction], + }], + onInput: [{ + eventName: 'test-input-event', + condition: temperatureEqual('31.6'), + actions: [setTemperatureAction], + }], + onExit: [{ + eventName: 'test-exit-event', + condition: temperatureEqual('31.7'), + actions: [setTemperatureAction], }], }); const offlineState = new iotevents.State({ stateName: 'offline', }); - // 1st => 2nd - onlineState.transitionTo(offlineState, { - when: iotevents.Expression.eq( - iotevents.Expression.inputAttribute(input, 'payload.temperature'), - iotevents.Expression.fromString('12'), - ), - }); - // 2st => 1st - offlineState.transitionTo(onlineState, { - when: iotevents.Expression.eq( - iotevents.Expression.inputAttribute(input, 'payload.temperature'), - iotevents.Expression.fromString('21'), - ), - }); + onlineState.transitionTo(offlineState, { when: temperatureEqual('12'), actions: [setTemperatureAction] }); + offlineState.transitionTo(onlineState, { when: temperatureEqual('21') }); new iotevents.DetectorModel(this, 'MyDetectorModel', { detectorModelName: 'test-detector-model',