diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/README.md b/packages/@aws-cdk/aws-stepfunctions-tasks/README.md index 59adc3517e704..e318bab630a99 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/README.md +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/README.md @@ -305,6 +305,25 @@ const invokeTask = new tasks.CallApiGatewayRestApiEndpoint(this, 'Call REST API' }); ``` +Be aware that the header values must be arrays. When passing the Task Token +in the headers field `WAIT_FOR_TASK_TOKEN` integration, use +`JsonPath.array()` to wrap the token in an array: + +```ts +import * as apigateway from '@aws-cdk/aws-apigateway'; +declare const api: apigateway.RestApi; + +new tasks.CallApiGatewayRestApiEndpoint(this, 'Endpoint', { + api, + stageName: 'Stage', + method: tasks.HttpMethod.PUT, + integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, + headers: sfn.TaskInput.fromObject({ + TaskToken: sfn.JsonPath.array(sfn.JsonPath.taskToken), + }), +}); +``` + ### Call HTTP API Endpoint The `CallApiGatewayHttpApiEndpoint` calls the HTTP API endpoint. @@ -798,7 +817,7 @@ The service integration APIs correspond to Amazon EMR on EKS APIs, but differ in ### Create Virtual Cluster -The [CreateVirtualCluster](https://docs.aws.amazon.com/emr-on-eks/latest/APIReference/API_CreateVirtualCluster.html) API creates a single virtual cluster that's mapped to a single Kubernetes namespace. +The [CreateVirtualCluster](https://docs.aws.amazon.com/emr-on-eks/latest/APIReference/API_CreateVirtualCluster.html) API creates a single virtual cluster that's mapped to a single Kubernetes namespace. The EKS cluster containing the Kubernetes namespace where the virtual cluster will be mapped can be passed in from the task input. diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-rest-api.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-rest-api.ts index 0352777e9c06a..153331cb6dafb 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-rest-api.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-rest-api.ts @@ -24,6 +24,25 @@ export interface CallApiGatewayRestApiEndpointProps extends CallApiGatewayEndpoi /** * Call REST API endpoint as a Task * + * Be aware that the header values must be arrays. When passing the Task Token + * in the headers field `WAIT_FOR_TASK_TOKEN` integration, use + * `JsonPath.array()` to wrap the token in an array: + * + * ```ts + * import * as apigateway from '@aws-cdk/aws-apigateway'; + * declare const api: apigateway.RestApi; + * + * new tasks.CallApiGatewayRestApiEndpoint(this, 'Endpoint', { + * api, + * stageName: 'Stage', + * method: tasks.HttpMethod.PUT, + * integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, + * headers: sfn.TaskInput.fromObject({ + * TaskToken: sfn.JsonPath.array(sfn.JsonPath.taskToken), + * }), + * }); + * ``` + * * @see https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html */ export class CallApiGatewayRestApiEndpoint extends CallApiGatewayEndpointBase { diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-rest-api.test.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-rest-api.test.ts index 37a083fb2cc95..f47ca69e0dc0a 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-rest-api.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-rest-api.test.ts @@ -69,7 +69,7 @@ describe('CallApiGatewayRestApiEndpoint', () => { method: HttpMethod.GET, stageName: 'dev', integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, - headers: sfn.TaskInput.fromObject({ TaskToken: sfn.JsonPath.taskToken }), + headers: sfn.TaskInput.fromObject({ TaskToken: sfn.JsonPath.array(sfn.JsonPath.taskToken) }), }); // THEN @@ -97,7 +97,7 @@ describe('CallApiGatewayRestApiEndpoint', () => { }, AuthType: 'NO_AUTH', Headers: { - 'TaskToken.$': '$$.Task.Token', + 'TaskToken.$': 'States.Array($$.Task.Token)', }, Method: 'GET', Stage: 'dev', diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/fields.ts b/packages/@aws-cdk/aws-stepfunctions/lib/fields.ts index 990a2542d4fea..73e30549c75b7 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/fields.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/fields.ts @@ -1,5 +1,5 @@ -import { Token } from '@aws-cdk/core'; -import { findReferencedPaths, jsonPathString, JsonPathToken, renderObject } from './json-path'; +import { Token, IResolvable } from '@aws-cdk/core'; +import { findReferencedPaths, jsonPathString, JsonPathToken, renderObject, renderInExpression, jsonPathFromAny } from './private/json-path'; /** * Extract a field from the State Machine data or context @@ -38,6 +38,15 @@ export class JsonPath { return Token.asNumber(new JsonPathToken(path)); } + /** + * Reference a complete (complex) object in a JSON path location + */ + public static objectAt(path: string): IResolvable { + validateJsonPath(path); + return new JsonPathToken(path); + } + + /** * Use the entire data structure * @@ -78,6 +87,82 @@ export class JsonPath { return new JsonPathToken('$$').toString(); } + /** + * Make an intrinsic States.Array expression + * + * Combine any number of string literals or JsonPath expressions into an array. + * + * Use this function if the value of an array element directly has to come + * from a JSON Path expression (either the State object or the Context object). + * + * If the array contains object literals whose values come from a JSON path + * expression, you do not need to use this function. + * + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-intrinsic-functions.html + */ + public static array(...values: string[]): string { + return new JsonPathToken(`States.Array(${values.map(renderInExpression).join(', ')})`).toString(); + } + + /** + * Make an intrinsic States.Format expression + * + * This can be used to embed JSON Path variables inside a format string. + * + * For example: + * + * ```ts + * sfn.JsonPath.format('Hello, my name is {}.', JsonPath.stringAt('$.name')) + * ``` + * + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-intrinsic-functions.html + */ + public static format(formatString: string, ...values: string[]): string { + const allArgs = [formatString, ...values]; + return new JsonPathToken(`States.Format(${allArgs.map(renderInExpression).join(', ')})`).toString(); + } + + /** + * Make an intrinsic States.StringToJson expression + * + * During the execution of the Step Functions state machine, parse the given + * argument as JSON into its object form. + * + * For example: + * + * ```ts + * sfn.JsonPath.stringToJson(JsonPath.stringAt('$.someJsonBody')) + * ``` + * + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-intrinsic-functions.html + */ + public static stringToJson(jsonString: string): IResolvable { + return new JsonPathToken(`States.StringToJson(${renderInExpression(jsonString)})`); + } + + /** + * Make an intrinsic States.JsonToString expression + * + * During the execution of the Step Functions state machine, encode the + * given object into a JSON string. + * + * For example: + * + * ```ts + * sfn.JsonPath.jsonToString(JsonPath.objectAt('$.someObject')) + * ``` + * + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-intrinsic-functions.html + */ + public static jsonToString(value: any): string { + const path = jsonPathFromAny(value); + if (!path) { + throw new Error('Argument to JsonPath.jsonToString() must be a JsonPath object'); + } + + return new JsonPathToken(`States.JsonToString(${path})`).toString(); + } + private constructor() {} } diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/private/intrinstics.ts b/packages/@aws-cdk/aws-stepfunctions/lib/private/intrinstics.ts new file mode 100644 index 0000000000000..04bd2abb20d29 --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions/lib/private/intrinstics.ts @@ -0,0 +1,246 @@ +export type IntrinsicExpression = StringLiteralExpression | PathExpression | FnCallExpression; +export type TopLevelIntrinsic = PathExpression | FnCallExpression; + +export interface StringLiteralExpression { + readonly type: 'string-literal'; + readonly literal: string; +} + +export interface PathExpression { + readonly type: 'path'; + readonly path: string; +} + +export interface FnCallExpression { + readonly type: 'fncall'; + readonly functionName: string; + readonly arguments: IntrinsicExpression[]; +} + +export class IntrinsicParser { + private i: number = 0; + + constructor(private readonly expression: string) { + } + + public parseTopLevelIntrinsic(): TopLevelIntrinsic { + this.ws(); + + const ret = + this.char() === '$' + ? this.parsePath() + : isAlphaNum(this.char()) + ? this.parseFnCall() + : this.raiseError("expected '$' or a function call"); + + this.ws(); + + if (!this.eof) { + this.raiseError('unexpected trailing characters'); + } + + return ret; + } + + public parseIntrinsic(): IntrinsicExpression { + this.ws(); + + if (this.char() === '$') { + return this.parsePath(); + } + + if (isAlphaNum(this.char())) { + return this.parseFnCall(); + } + + if (this.char() === "'") { + return this.parseStringLiteral(); + } + + this.raiseError('expected $, function or single-quoted string'); + } + + /** + * Simplified path parsing + * + * JSON path can actually be quite complicated, but we don't need to validate + * it precisely. We just need to know how far it extends. + * + * Therefore, we only care about: + * + * - Starts with a $ + * - Accept ., $ and alphanums + * - Accept single-quoted strings ('...') + * - Accept anything between matched square brackets ([...]) + */ + public parsePath(): PathExpression { + const pathString = new Array(); + if (this.char() !== '$') { + this.raiseError('expected \'$\''); + } + pathString.push(this.consume()); + + let done = false; + while (!done && !this.eof) { + switch (this.char()) { + case '.': + case '$': + pathString.push(this.consume()); + break; + case "'": + const { quoted } = this.consumeQuotedString(); + pathString.push(quoted); + break; + + case '[': + pathString.push(this.consumeBracketedExpression(']')); + break; + + default: + if (isAlphaNum(this.char())) { + pathString.push(this.consume()); + break; + } + + // Not alphanum, end of path expression + done = true; + } + } + + return { type: 'path', path: pathString.join('') }; + } + + /** + * Parse a fncall + * + * Cursor should be on call identifier. Afterwards, cursor will be on closing + * quote. + */ + public parseFnCall(): FnCallExpression { + const name = new Array(); + while (this.char() !== '(') { + name.push(this.consume()); + } + + this.next(); // Consume the '(' + this.ws(); + + const args = []; + while (this.char() !== ')') { + args.push(this.parseIntrinsic()); + this.ws(); + + if (this.char() === ',') { + this.next(); + continue; + } else if (this.char() === ')') { + continue; + } else { + this.raiseError('expected , or )'); + } + } + this.next(); // Consume ')' + + return { + type: 'fncall', + arguments: args, + functionName: name.join(''), + }; + } + + /** + * Parse a string literal + * + * Cursor is expected to be on the first opening quote. Afterwards, + * cursor will be after the closing quote. + */ + public parseStringLiteral(): StringLiteralExpression { + const { unquoted } = this.consumeQuotedString(); + return { type: 'string-literal', literal: unquoted }; + } + + /** + * Parse a bracketed expression + * + * Cursor is expected to be on the opening brace. Afterwards, + * the cursor will be after the closing brace. + */ + private consumeBracketedExpression(closingBrace: string): string { + const ret = new Array(); + ret.push(this.consume()); + while (this.char() !== closingBrace) { + if (this.char() === '[') { + ret.push(this.consumeBracketedExpression(']')); + } else if (this.char() === '{') { + ret.push(this.consumeBracketedExpression('}')); + } else { + ret.push(this.consume()); + } + } + ret.push(this.consume()); + return ret.join(''); + } + + /** + * Parse a string literal + * + * Cursor is expected to be on the first opening quote. Afterwards, + * cursor will be after the closing quote. + */ + private consumeQuotedString(): { readonly quoted: string; unquoted: string } { + const quoted = new Array(); + const unquoted = new Array(); + + quoted.push(this.consume()); + while (this.char() !== "'") { + if (this.char() === '\\') { + // Advance and add next character literally, whatever it is + quoted.push(this.consume()); + } + quoted.push(this.char()); + unquoted.push(this.char()); + this.next(); + } + quoted.push(this.consume()); + return { quoted: quoted.join(''), unquoted: unquoted.join('') }; + } + + /** + * Consume whitespace if it exists + */ + private ws() { + while (!this.eof && [' ', '\t', '\n'].includes(this.char())) { + this.next(); + } + } + + private get eof() { + return this.i >= this.expression.length; + } + + private char(): string { + if (this.eof) { + this.raiseError('unexpected end of string'); + } + + return this.expression[this.i]; + } + + private next() { + this.i++; + } + + private consume() { + const ret = this.char(); + this.next(); + return ret; + } + + private raiseError(message: string): never { + throw new Error(`Invalid JSONPath expression: ${message} at index ${this.i} in ${JSON.stringify(this.expression)}`); + } +} + +function isAlphaNum(x: string) { + return x.match(/^[a-zA-Z0-9]$/); +} diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/json-path.ts b/packages/@aws-cdk/aws-stepfunctions/lib/private/json-path.ts similarity index 76% rename from packages/@aws-cdk/aws-stepfunctions/lib/json-path.ts rename to packages/@aws-cdk/aws-stepfunctions/lib/private/json-path.ts index b4602b5e887d0..2feef1803176b 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/json-path.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/private/json-path.ts @@ -1,4 +1,5 @@ import { captureStackTrace, IResolvable, IResolveContext, Token, Tokenization } from '@aws-cdk/core'; +import { IntrinsicParser, IntrinsicExpression } from './intrinstics'; const JSON_PATH_TOKEN_SYMBOL = Symbol.for('@aws-cdk/aws-stepfunctions.JsonPathToken'); @@ -49,20 +50,23 @@ export function findReferencedPaths(obj: object | undefined): Set { recurseObject(obj, { handleString(_key: string, x: string) { - const path = jsonPathString(x); - if (path !== undefined) { found.add(path); } + for (const p of findPathsInIntrinsicFunctions(jsonPathString(x))) { + found.add(p); + } return {}; }, handleList(_key: string, x: string[]) { - const path = jsonPathStringList(x); - if (path !== undefined) { found.add(path); } + for (const p of findPathsInIntrinsicFunctions(jsonPathStringList(x))) { + found.add(p); + } return {}; }, handleNumber(_key: string, x: number) { - const path = jsonPathNumber(x); - if (path !== undefined) { found.add(path); } + for (const p of findPathsInIntrinsicFunctions(jsonPathNumber(x))) { + found.add(p); + } return {}; }, @@ -74,6 +78,38 @@ export function findReferencedPaths(obj: object | undefined): Set { return found; } +/** + * From an expression, return the list of JSON paths referenced in it + */ +function findPathsInIntrinsicFunctions(expression?: string): string[] { + if (!expression) { return []; } + + const ret = new Array(); + + try { + const parsed = new IntrinsicParser(expression).parseTopLevelIntrinsic(); + recurse(parsed); + return ret; + } catch (e) { + // Not sure that our parsing is 100% correct. We don't want to break anyone, so + // fall back to legacy behavior if we can't parse this string. + return [expression]; + } + + function recurse(p: IntrinsicExpression) { + switch (p.type) { + case 'path': + ret.push(p.path); + break; + + case 'fncall': + for (const arg of p.arguments) { + recurse(arg); + } + } + } +} + interface FieldHandlers { handleString(key: string, x: string): {[key: string]: string}; handleList(key: string, x: string[]): {[key: string]: string[] | string }; @@ -219,6 +255,12 @@ export function jsonPathString(x: string): string | undefined { return undefined; } +export function jsonPathFromAny(x: any) { + if (!x) { return undefined; } + if (typeof x === 'string') { return jsonPathString(x); } + return pathFromToken(Tokenization.reverse(x)); +} + /** * If the indicated string list is an encoded JSON path, return the path * @@ -240,3 +282,35 @@ function jsonPathNumber(x: number): string | undefined { function pathFromToken(token: IResolvable | undefined) { return token && (JsonPathToken.isJsonPathToken(token) ? token.path : undefined); } + +/** + * Render the string in a valid JSON Path expression. + * + * If the string is a Tokenized JSON path reference -- return the JSON path reference inside it. + * Otherwise, single-quote it. + * + * Call this function whenever you're building compound JSONPath expressions, in + * order to avoid having tokens-in-tokens-in-tokens which become very hard to parse. + */ +export function renderInExpression(x: string) { + const path = jsonPathString(x); + return path ?? singleQuotestring(x); +} + +function singleQuotestring(x: string) { + const ret = new Array(); + ret.push("'"); + for (const c of x) { + if (c === "'") { + ret.push("\\'"); + } else if (c === '\\') { + ret.push('\\\\'); + } else if (c === '\n') { + ret.push('\\n'); + } else { + ret.push(c); + } + } + ret.push("'"); + return ret.join(''); +} \ No newline at end of file