Skip to content

Commit

Permalink
fix(stepfunctions): cannot use intrinsic functions in Fail state (#30210
Browse files Browse the repository at this point in the history
)

### Issue # (if applicable)

Closes #30063

### Reason for this change
In the Fail state, we can specify intrinsic functions and json paths as the CausePath and ErrorPath properties.
Currently, however, specifying intrinsic functions as a string will result in an error.
https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-fail-state.html

```ts
export class SampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const fail = new stepfunctions.Fail(this, "Fail", {
      errorPath: "$.error", // OK
      causePath: "States.Format('cause: {}', $.cause)", // Error
    });

    const sm = new stepfunctions.StateMachine(this, "StateMachine", {
      definitionBody: stepfunctions.DefinitionBody.fromChainable(fail),
      timeout: cdk.Duration.minutes(5)
    });
  }
}
```
```
Error: Expected JSON path to start with '$', got: States.Format('cause: {}', $.cause)
```

### Description of changes
The value passed to the `renderJsonPath` function is expected to be a string starting with `$` if it is not a token.
However, if you pass intrinsic functions as strings to the CausePath and ErrorPath properties, they will never start with `$`.
Therefore, I fixed not to call the `renderJsonPath` function if the intrinsic functions are specified as strings.

Another change was the addition of validation since error and errorPath, cause and causePath cannot be specified simultaneously.

### Description of how you validated changes
I added unit tests to verify that passing intrinsic functions as strings do not cause an error.

Tests were also added to verify that errors occur when errors and paths are specified at the same time and when cause and cause paths are specified at the same time.
https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-fail-state.html#:~:text=%2C%20and%20States.UUID.-,Important,-You%20can%20specify%20either%20Cause
https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-fail-state.html#:~:text=%2C%20and%20States.UUID.-,Important,-You%20can%20specify%20either%20Error

### Checklist
- [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md)

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
sakurai-ryo committed May 30, 2024
1 parent 8b234b7 commit 81a558f
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 4 deletions.
10 changes: 10 additions & 0 deletions packages/aws-cdk-lib/aws-stepfunctions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,16 @@ const fail = new sfn.Fail(this, 'Fail', {
});
```

You can also use an intrinsic function that returns a string to specify CausePath and ErrorPath.
The available functions include States.Format, States.JsonToString, States.ArrayGetItem, States.Base64Encode, States.Base64Decode, States.Hash, and States.UUID.

```ts
const fail = new sfn.Fail(this, 'Fail', {
errorPath: sfn.JsonPath.format('error: {}.', sfn.JsonPath.stringAt('$.someError')),
causePath: "States.Format('cause: {}.', $.someCause)",
});
```

### Map

A `Map` state can be used to run a set of steps for each element of an input array.
Expand Down
56 changes: 53 additions & 3 deletions packages/aws-cdk-lib/aws-stepfunctions/lib/states/fail.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Construct } from 'constructs';
import { StateType } from './private/state-type';
import { renderJsonPath, State } from './state';
import { Token } from '../../../core';
import { INextable } from '../types';

/**
Expand Down Expand Up @@ -31,6 +32,9 @@ export interface FailProps {
/**
* JsonPath expression to select part of the state to be the error to this state.
*
* You can also use an intrinsic function that returns a string to specify this property.
* The allowed functions include States.Format, States.JsonToString, States.ArrayGetItem, States.Base64Encode, States.Base64Decode, States.Hash, and States.UUID.
*
* @default - No error path
*/
readonly errorPath?: string;
Expand All @@ -45,6 +49,9 @@ export interface FailProps {
/**
* JsonPath expression to select part of the state to be the cause to this state.
*
* You can also use an intrinsic function that returns a string to specify this property.
* The allowed functions include States.Format, States.JsonToString, States.ArrayGetItem, States.Base64Encode, States.Base64Decode, States.Hash, and States.UUID.
*
* @default - No cause path
*/
readonly causePath?: string;
Expand All @@ -56,6 +63,16 @@ export interface FailProps {
* Reaching a Fail state terminates the state execution in failure.
*/
export class Fail extends State {
private static allowedIntrinsics = [
'States.Format',
'States.JsonToString',
'States.ArrayGetItem',
'States.Base64Encode',
'States.Base64Decode',
'States.Hash',
'States.UUID',
];

public readonly endStates: INextable[] = [];

private readonly error?: string;
Expand All @@ -80,9 +97,42 @@ export class Fail extends State {
Type: StateType.FAIL,
Comment: this.comment,
Error: this.error,
ErrorPath: renderJsonPath(this.errorPath),
ErrorPath: this.isIntrinsicString(this.errorPath) ? this.errorPath : renderJsonPath(this.errorPath),
Cause: this.cause,
CausePath: renderJsonPath(this.causePath),
CausePath: this.isIntrinsicString(this.causePath) ? this.causePath : renderJsonPath(this.causePath),
};
}
}

/**
* Validate this state
*/
protected validateState(): string[] {
const errors = super.validateState();

if (this.errorPath && this.isIntrinsicString(this.errorPath) && !this.isAllowedIntrinsic(this.errorPath)) {
errors.push(`You must specify a valid intrinsic function in errorPath. Must be one of ${Fail.allowedIntrinsics.join(', ')}`);
}

if (this.causePath && this.isIntrinsicString(this.causePath) && !this.isAllowedIntrinsic(this.causePath)) {
errors.push(`You must specify a valid intrinsic function in causePath. Must be one of ${Fail.allowedIntrinsics.join(', ')}`);
}

if (this.error && this.errorPath) {
errors.push('Fail state cannot have both error and errorPath');
}

if (this.cause && this.causePath) {
errors.push('Fail state cannot have both cause and causePath');
}

return errors;
}

private isIntrinsicString(jsonPath?: string): boolean {
return !Token.isUnresolved(jsonPath) && !jsonPath?.startsWith('$');
}

private isAllowedIntrinsic(intrinsic: string): boolean {
return Fail.allowedIntrinsics.some(allowed => intrinsic.startsWith(allowed));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,88 @@ describe('State Machine Resources', () => {
});
}),

test.each([
[
"States.Format('error: {}.', $.error)",
"States.Format('cause: {}.', $.cause)",
],
[
stepfunctions.JsonPath.format('error: {}.', stepfunctions.JsonPath.stringAt('$.error')),
stepfunctions.JsonPath.format('cause: {}.', stepfunctions.JsonPath.stringAt('$.cause')),
],
])('Fail should render ErrorPath / CausePath correctly when specifying ErrorPath / CausePath using intrinsics', (errorPath, causePath) => {
// GIVEN
const app = new cdk.App();
const stack = new cdk.Stack(app);
const fail = new stepfunctions.Fail(stack, 'Fail', {
errorPath,
causePath,
});

// WHEN
const failState = stack.resolve(fail.toStateJson());

// THEN
expect(failState).toStrictEqual({
CausePath: "States.Format('cause: {}.', $.cause)",
ErrorPath: "States.Format('error: {}.', $.error)",
Type: 'Fail',
});
expect(() => app.synth()).not.toThrow();
}),

test('fails in synthesis if error and errorPath are defined in Fail state', () => {
// GIVEN
const app = new cdk.App();
const stack = new cdk.Stack(app);

// WHEN
new stepfunctions.Fail(stack, 'Fail', {
error: 'error',
errorPath: '$.error',
});

expect(() => app.synth()).toThrow(/Fail state cannot have both error and errorPath/);
}),

test('fails in synthesis if cause and causePath are defined in Fail state', () => {
// GIVEN
const app = new cdk.App();
const stack = new cdk.Stack(app);

// WHEN
new stepfunctions.Fail(stack, 'Fail', {
cause: 'cause',
causePath: '$.cause',
});

expect(() => app.synth()).toThrow(/Fail state cannot have both cause and causePath/);
}),

test.each([
'States.Array($.Id)',
'States.ArrayPartition($.inputArray, 4)',
'States.ArrayContains($.inputArray, $.lookingFor)',
'States.ArrayRange(1, 9, 2)',
'States.ArrayLength($.inputArray)',
'States.JsonMerge($.json1, $.json2, false)',
'States.StringToJson($.escapedJsonString)',
'plainString',
])('fails in synthesis if specifying invalid intrinsic functions in the causePath and errorPath (%s)', (intrinsic) => {
// GIVEN
const app = new cdk.App();
const stack = new cdk.Stack(app);

// WHEN
new stepfunctions.Fail(stack, 'Fail', {
causePath: intrinsic,
errorPath: intrinsic,
});

expect(() => app.synth()).toThrow(/You must specify a valid intrinsic function in causePath. Must be one of States.Format, States.JsonToString, States.ArrayGetItem, States.Base64Encode, States.Base64Decode, States.Hash, States.UUID/);
expect(() => app.synth()).toThrow(/You must specify a valid intrinsic function in errorPath. Must be one of States.Format, States.JsonToString, States.ArrayGetItem, States.Base64Encode, States.Base64Decode, States.Hash, States.UUID/);
}),

testDeprecated('Task should render InputPath / Parameters / OutputPath correctly', () => {
// GIVEN
const stack = new cdk.Stack();
Expand Down Expand Up @@ -721,7 +803,6 @@ describe('State Machine Resources', () => {
],
});
});

});

interface FakeTaskProps extends stepfunctions.TaskStateBaseProps {
Expand Down

0 comments on commit 81a558f

Please sign in to comment.