diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index 4789a8cc62410..83e9d04cd31c6 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -22,6 +22,7 @@ running on AWS Lambda, or any web application. - [Defining APIs](#defining-apis) - [Breaking up Methods and Resources across Stacks](#breaking-up-methods-and-resources-across-stacks) - [AWS Lambda-backed APIs](#aws-lambda-backed-apis) +- [AWS StepFunctions backed APIs](#aws-stepfunctions-backed-APIs) - [Integration Targets](#integration-targets) - [Usage Plan & API Keys](#usage-plan--api-keys) - [Working with models](#working-with-models) @@ -106,6 +107,121 @@ item.addMethod('GET'); // GET /items/{item} item.addMethod('DELETE', new apigateway.HttpIntegration('http://amazon.com')); ``` +## AWS StepFunctions backed APIs + +You can use Amazon API Gateway with AWS Step Functions as the backend integration, specifically Synchronous Express Workflows. + +The `StepFunctionsRestApi` only supports integration with Synchronous Express state machine. The `StepFunctionsRestApi` construct makes this easy by setting up input, output and error mapping. + +The construct sets up an API endpoint and maps the `ANY` HTTP method and any calls to the API endpoint starts an express workflow execution for the underlying state machine. + +Invoking the endpoint with any HTTP method (`GET`, `POST`, `PUT`, `DELETE`, ...) in the example below will send the request to the state machine as a new execution. On success, an HTTP code `200` is returned with the execution output as the Response Body. + +If the execution fails, an HTTP `500` response is returned with the `error` and `cause` from the execution output as the Response Body. If the request is invalid (ex. bad execution input) HTTP code `400` is returned. + +The response from the invocation contains only the `output` field from the +[StartSyncExecution](https://docs.aws.amazon.com/step-functions/latest/apireference/API_StartSyncExecution.html#API_StartSyncExecution_ResponseSyntax) API. +In case of failures, the fields `error` and `cause` are returned as part of the response. +Other metadata such as billing details, AWS account ID and resource ARNs are not returned in the API response. + +By default, a `prod` stage is provisioned. + +In order to reduce the payload size sent to AWS Step Functions, `headers` are not forwarded to the Step Functions execution input. It is possible to choose whether `headers`, `requestContext`, `path` and `querystring` are included or not. By default, `headers` are excluded in all requests. + +More details about AWS Step Functions payload limit can be found at https://docs.aws.amazon.com/step-functions/latest/dg/limits-overview.html#service-limits-task-executions. + +The following code defines a REST API that routes all requests to the specified AWS StepFunctions state machine: + +```ts +const stateMachineDefinition = new stepfunctions.Pass(this, 'PassState'); + +const stateMachine: stepfunctions.IStateMachine = new stepfunctions.StateMachine(this, 'StateMachine', { + definition: stateMachineDefinition, + stateMachineType: stepfunctions.StateMachineType.EXPRESS, +}); + +new apigateway.StepFunctionsRestApi(this, 'StepFunctionsRestApi', { + deploy: true, + stateMachine: stateMachine, +}); +``` + +When the REST API endpoint configuration above is invoked using POST, as follows - + +```bash +curl -X POST -d '{ "customerId": 1 }' https://example.com/ +``` + +AWS Step Functions will receive the request body in its input as follows: + +```json +{ + "body": { + "customerId": 1 + }, + "path": "/", + "querystring": {} +} +``` + +When the endpoint is invoked at path '/users/5' using the HTTP GET method as below: + +```bash +curl -X GET https://example.com/users/5?foo=bar +``` + +AWS Step Functions will receive the following execution input: + +```json +{ + "body": {}, + "path": { + "users": "5" + }, + "querystring": { + "foo": "bar" + } +} +``` + +Additional information around the request such as the request context and headers can be included as part of the input +forwarded to the state machine. The following example enables headers to be included in the input but not query string. + +```ts fixture=stepfunctions +new apigateway.StepFunctionsRestApi(this, 'StepFunctionsRestApi', { + stateMachine: machine, + headers: true, + path: false, + querystring: false, + requestContext: { + caller: true, + user: true, + }, +}); +``` + +In such a case, when the endpoint is invoked as below: + +```bash +curl -X GET https://example.com/ +``` + +AWS Step Functions will receive the following execution input: + +```json +{ + "headers": { + "Accept": "...", + "CloudFront-Forwarded-Proto": "...", + }, + "requestContext": { + "accountId": "...", + "apiKey": "...", + }, + "body": {} +} +``` + ### Breaking up Methods and Resources across Stacks It is fairly common for REST APIs with a large number of Resources and Methods to hit the [CloudFormation diff --git a/packages/@aws-cdk/aws-apigateway/lib/index.ts b/packages/@aws-cdk/aws-apigateway/lib/index.ts index 4c288b27f4160..58b8150e2ebf9 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/index.ts @@ -21,6 +21,7 @@ export * from './authorizers'; export * from './access-log'; export * from './api-definition'; export * from './gateway-response'; +export * from './stepfunctions-api'; // AWS::ApiGateway CloudFormation Resources: export * from './apigateway.generated'; diff --git a/packages/@aws-cdk/aws-apigateway/lib/integrations/index.ts b/packages/@aws-cdk/aws-apigateway/lib/integrations/index.ts index 1369c366d655f..9ebc36bf92a58 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/integrations/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/integrations/index.ts @@ -2,3 +2,5 @@ export * from './aws'; export * from './lambda'; export * from './http'; export * from './mock'; +export * from './stepfunctions'; +export * from './request-context'; diff --git a/packages/@aws-cdk/aws-apigateway/lib/integrations/request-context.ts b/packages/@aws-cdk/aws-apigateway/lib/integrations/request-context.ts new file mode 100644 index 0000000000000..12847584f5958 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/integrations/request-context.ts @@ -0,0 +1,161 @@ +/** + * Configure what must be included in the `requestContext` + * + * More details can be found at mapping templates documentation. + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + */ +export interface RequestContext { + /** + * Represents the information of $context.identity.accountId + * + * Whether the AWS account of the API owner should be included in the request context + * @default false + */ + readonly accountId?: boolean; + + /** + * Represents the information of $context.apiId + * + * Whether the identifier API Gateway assigns to your API should be included in the request context. + * @default false + */ + readonly apiId?: boolean; + + /** + * Represents the information of $context.identity.apiKey + * + * Whether the API key associated with the request should be included in request context. + * @default false + */ + readonly apiKey?: boolean; + + /** + * Represents the information of $context.authorizer.principalId + * + * Whether the principal user identifier associated with the token sent by the client and returned + * from an API Gateway Lambda authorizer should be included in the request context. + * @default false + */ + readonly authorizerPrincipalId?: boolean; + + /** + * Represents the information of $context.identity.caller + * + * Whether the principal identifier of the caller that signed the request should be included in the request context. + * Supported for resources that use IAM authorization. + * @default false + */ + readonly caller?: boolean; + + /** + * Represents the information of $context.identity.cognitoAuthenticationProvider + * + * Whether the list of the Amazon Cognito authentication providers used by the caller making the request should be included in the request context. + * Available only if the request was signed with Amazon Cognito credentials. + * @default false + */ + readonly cognitoAuthenticationProvider?: boolean; + + /** + * Represents the information of $context.identity.cognitoAuthenticationType + * + * Whether the Amazon Cognito authentication type of the caller making the request should be included in the request context. + * Available only if the request was signed with Amazon Cognito credentials. + * Possible values include authenticated for authenticated identities and unauthenticated for unauthenticated identities. + * @default false + */ + readonly cognitoAuthenticationType?: boolean; + + /** + * Represents the information of $context.identity.cognitoIdentityId + * + * Whether the Amazon Cognito identity ID of the caller making the request should be included in the request context. + * Available only if the request was signed with Amazon Cognito credentials. + * @default false + */ + readonly cognitoIdentityId?: boolean; + + /** + * Represents the information of $context.identity.cognitoIdentityPoolId + * + * Whether the Amazon Cognito identity pool ID of the caller making the request should be included in the request context. + * Available only if the request was signed with Amazon Cognito credentials. + * @default false + */ + readonly cognitoIdentityPoolId?: boolean; + + /** + * Represents the information of $context.httpMethod + * + * Whether the HTTP method used should be included in the request context. + * Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT. + * @default false + */ + readonly httpMethod?: boolean; + + /** + * Represents the information of $context.stage + * + * Whether the deployment stage of the API request should be included in the request context. + * @default false + */ + readonly stage?: boolean; + + /** + * Represents the information of $context.identity.sourceIp + * + * Whether the source IP address of the immediate TCP connection making the request + * to API Gateway endpoint should be included in the request context. + * @default false + */ + readonly sourceIp?: boolean; + + /** + * Represents the information of $context.identity.user + * + * Whether the principal identifier of the user that will be authorized should be included in the request context. + * Supported for resources that use IAM authorization. + * @default false + */ + readonly user?: boolean; + + /** + * Represents the information of $context.identity.userAgent + * + * Whether the User-Agent header of the API caller should be included in the request context. + * @default false + */ + readonly userAgent?: boolean; + + /** + * Represents the information of $context.identity.userArn + * + * Whether the Amazon Resource Name (ARN) of the effective user identified after authentication should be included in the request context. + * @default false + */ + readonly userArn?: boolean; + + /** + * Represents the information of $context.requestId + * + * Whether the ID for the request should be included in the request context. + * @default false + */ + readonly requestId?: boolean; + + /** + * Represents the information of $context.resourceId + * + * Whether the identifier that API Gateway assigns to your resource should be included in the request context. + * @default false + */ + readonly resourceId?: boolean; + + /** + * Represents the information of $context.resourcePath + * + * Whether the path to the resource should be included in the request context. + * @default false + */ + readonly resourcePath?: boolean; +} diff --git a/packages/@aws-cdk/aws-apigateway/lib/integrations/stepfunctions.ts b/packages/@aws-cdk/aws-apigateway/lib/integrations/stepfunctions.ts new file mode 100644 index 0000000000000..339e7b2949198 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/integrations/stepfunctions.ts @@ -0,0 +1,288 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as iam from '@aws-cdk/aws-iam'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import { Token } from '@aws-cdk/core'; +import { RequestContext } from '.'; +import { IntegrationConfig, IntegrationOptions, PassthroughBehavior } from '../integration'; +import { Method } from '../method'; +import { AwsIntegration } from './aws'; +/** + * Options when configuring Step Functions synchronous integration with Rest API + */ +export interface StepFunctionsExecutionIntegrationOptions extends IntegrationOptions { + + /** + * Which details of the incoming request must be passed onto the underlying state machine, + * such as, account id, user identity, request id, etc. The execution input will include a new key `requestContext`: + * + * { + * "body": {}, + * "requestContext": { + * "key": "value" + * } + * } + * + * @default - all parameters within request context will be set as false + */ + readonly requestContext?: RequestContext; + + /** + * Check if querystring is to be included inside the execution input. The execution input will include a new key `queryString`: + * + * { + * "body": {}, + * "querystring": { + * "key": "value" + * } + * } + * + * @default true + */ + readonly querystring?: boolean; + + /** + * Check if path is to be included inside the execution input. The execution input will include a new key `path`: + * + * { + * "body": {}, + * "path": { + * "resourceName": "resourceValue" + * } + * } + * + * @default true + */ + readonly path?: boolean; + + /** + * Check if header is to be included inside the execution input. The execution input will include a new key `headers`: + * + * { + * "body": {}, + * "headers": { + * "header1": "value", + * "header2": "value" + * } + * } + * @default false + */ + readonly headers?: boolean; +} + +/** + * Options to integrate with various StepFunction API + */ +export class StepFunctionsIntegration { + /** + * Integrates a Synchronous Express State Machine from AWS Step Functions to an API Gateway method. + * + * @example + * + * const stateMachine = new stepfunctions.StateMachine(this, 'MyStateMachine', { + * definition: stepfunctions.Chain.start(new stepfunctions.Pass(this, 'Pass')), + * }); + * + * const api = new apigateway.RestApi(this, 'Api', { + * restApiName: 'MyApi', + * }); + * api.root.addMethod('GET', apigateway.StepFunctionsIntegration.startExecution(stateMachine)); + */ + public static startExecution(stateMachine: sfn.IStateMachine, options?: StepFunctionsExecutionIntegrationOptions): AwsIntegration { + return new StepFunctionsExecutionIntegration(stateMachine, options); + } +} + +class StepFunctionsExecutionIntegration extends AwsIntegration { + private readonly stateMachine: sfn.IStateMachine; + constructor(stateMachine: sfn.IStateMachine, options: StepFunctionsExecutionIntegrationOptions = {}) { + super({ + service: 'states', + action: 'StartSyncExecution', + options: { + credentialsRole: options.credentialsRole, + integrationResponses: integrationResponse(), + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: requestTemplates(stateMachine, options), + ...options, + }, + }); + + this.stateMachine = stateMachine; + } + + public bind(method: Method): IntegrationConfig { + const bindResult = super.bind(method); + const principal = new iam.ServicePrincipal('apigateway.amazonaws.com'); + + this.stateMachine.grantExecution(principal, 'states:StartSyncExecution'); + + let stateMachineName; + + if (this.stateMachine instanceof sfn.StateMachine) { + const stateMachineType = (this.stateMachine as sfn.StateMachine).stateMachineType; + if (stateMachineType !== sfn.StateMachineType.EXPRESS) { + throw new Error('State Machine must be of type "EXPRESS". Please use StateMachineType.EXPRESS as the stateMachineType'); + } + + //if not imported, extract the name from the CFN layer to reach the + //literal value if it is given (rather than a token) + stateMachineName = (this.stateMachine.node.defaultChild as sfn.CfnStateMachine).stateMachineName; + } else { + //imported state machine + stateMachineName = `StateMachine-${this.stateMachine.stack.node.addr}`; + } + + let deploymentToken; + + if (stateMachineName !== undefined && !Token.isUnresolved(stateMachineName)) { + deploymentToken = JSON.stringify({ stateMachineName }); + } + return { + ...bindResult, + deploymentToken, + }; + } +} + +/** + * Defines the integration response that passes the result on success, + * or the error on failure, from the synchronous execution to the caller. + * + * @returns integrationResponse mapping + */ +function integrationResponse() { + const errorResponse = [ + { + /** + * Specifies the regular expression (regex) pattern used to choose + * an integration response based on the response from the back end. + * In this case it will match all '4XX' HTTP Errors + */ + selectionPattern: '4\\d{2}', + statusCode: '400', + responseTemplates: { + 'application/json': `{ + "error": "Bad request!" + }`, + }, + }, + { + /** + * Match all '5XX' HTTP Errors + */ + selectionPattern: '5\\d{2}', + statusCode: '500', + responseTemplates: { + 'application/json': '"error": $input.path(\'$.error\')', + }, + }, + ]; + + const integResponse = [ + { + statusCode: '200', + responseTemplates: { + /* eslint-disable */ + 'application/json': [ + '#set($inputRoot = $input.path(\'$\'))', + '#if($input.path(\'$.status\').toString().equals("FAILED"))', + '#set($context.responseOverride.status = 500)', + '{', + '"error": "$input.path(\'$.error\')"', + '"cause": "$input.path(\'$.cause\')"', + '}', + '#else', + '$input.path(\'$.output\')', + '#end', + /* eslint-enable */ + ].join('\n'), + }, + }, + ...errorResponse, + ]; + + return integResponse; +} + +/** + * Defines the request template that will be used for the integration + * @param stateMachine + * @param options + * @returns requestTemplate + */ +function requestTemplates(stateMachine: sfn.IStateMachine, options: StepFunctionsExecutionIntegrationOptions) { + const templateStr = templateString(stateMachine, options); + + const requestTemplate: { [contentType:string] : string } = + { + 'application/json': templateStr, + }; + + return requestTemplate; +} + +/** + * Reads the VTL template and returns the template string to be used + * for the request template. + * + * @param stateMachine + * @param includeRequestContext + * @param options + * @reutrns templateString + */ +function templateString( + stateMachine: sfn.IStateMachine, + options: StepFunctionsExecutionIntegrationOptions): string { + let templateStr: string; + + let requestContextStr = ''; + + const includeHeader = options.headers?? false; + const includeQueryString = options.querystring?? true; + const includePath = options.path?? true; + + if (options.requestContext && Object.keys(options.requestContext).length > 0) { + requestContextStr = requestContext(options.requestContext); + } + + templateStr = fs.readFileSync(path.join(__dirname, 'stepfunctions.vtl'), { encoding: 'utf-8' }); + templateStr = templateStr.replace('%STATEMACHINE%', stateMachine.stateMachineArn); + templateStr = templateStr.replace('%INCLUDE_HEADERS%', String(includeHeader)); + templateStr = templateStr.replace('%INCLUDE_QUERYSTRING%', String(includeQueryString)); + templateStr = templateStr.replace('%INCLUDE_PATH%', String(includePath)); + templateStr = templateStr.replace('%REQUESTCONTEXT%', requestContextStr); + + return templateStr; +} + +function requestContext(requestContextObj: RequestContext | undefined): string { + const context = { + accountId: requestContextObj?.accountId? '$context.identity.accountId': undefined, + apiId: requestContextObj?.apiId? '$context.apiId': undefined, + apiKey: requestContextObj?.apiKey? '$context.identity.apiKey': undefined, + authorizerPrincipalId: requestContextObj?.authorizerPrincipalId? '$context.authorizer.principalId': undefined, + caller: requestContextObj?.caller? '$context.identity.caller': undefined, + cognitoAuthenticationProvider: requestContextObj?.cognitoAuthenticationProvider? '$context.identity.cognitoAuthenticationProvider': undefined, + cognitoAuthenticationType: requestContextObj?.cognitoAuthenticationType? '$context.identity.cognitoAuthenticationType': undefined, + cognitoIdentityId: requestContextObj?.cognitoIdentityId? '$context.identity.cognitoIdentityId': undefined, + cognitoIdentityPoolId: requestContextObj?.cognitoIdentityPoolId? '$context.identity.cognitoIdentityPoolId': undefined, + httpMethod: requestContextObj?.httpMethod? '$context.httpMethod': undefined, + stage: requestContextObj?.stage? '$context.stage': undefined, + sourceIp: requestContextObj?.sourceIp? '$context.identity.sourceIp': undefined, + user: requestContextObj?.user? '$context.identity.user': undefined, + userAgent: requestContextObj?.userAgent? '$context.identity.userAgent': undefined, + userArn: requestContextObj?.userArn? '$context.identity.userArn': undefined, + requestId: requestContextObj?.requestId? '$context.requestId': undefined, + resourceId: requestContextObj?.resourceId? '$context.resourceId': undefined, + resourcePath: requestContextObj?.resourcePath? '$context.resourcePath': undefined, + }; + + const contextAsString = JSON.stringify(context); + + // The VTL Template conflicts with double-quotes (") for strings. + // Before sending to the template, we replace double-quotes (") with @@ and replace it back inside the .vtl file + const doublequotes = '"'; + const replaceWith = '@@'; + return contextAsString.split(doublequotes).join(replaceWith); +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/lib/integrations/stepfunctions.vtl b/packages/@aws-cdk/aws-apigateway/lib/integrations/stepfunctions.vtl new file mode 100644 index 0000000000000..df4eda7c279d5 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/integrations/stepfunctions.vtl @@ -0,0 +1,63 @@ +## Velocity Template used for API Gateway request mapping template +## +## This template forwards the request body, header, path, and querystring +## to the execution input of the state machine. +## +## "@@" is used here as a placeholder for '"' to avoid using escape characters. + +#set($inputString = '') +#set($includeHeaders = %INCLUDE_HEADERS%) +#set($includeQueryString = %INCLUDE_QUERYSTRING%) +#set($includePath = %INCLUDE_PATH%) +#set($allParams = $input.params()) +{ + "stateMachineArn": "%STATEMACHINE%", + + #set($inputString = "$inputString,@@body@@: $input.body") + + #if ($includeHeaders) + #set($inputString = "$inputString, @@header@@:{") + #foreach($paramName in $allParams.header.keySet()) + #set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.header.get($paramName))@@") + #if($foreach.hasNext) + #set($inputString = "$inputString,") + #end + #end + #set($inputString = "$inputString }") + + #end + + #if ($includeQueryString) + #set($inputString = "$inputString, @@querystring@@:{") + #foreach($paramName in $allParams.querystring.keySet()) + #set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.querystring.get($paramName))@@") + #if($foreach.hasNext) + #set($inputString = "$inputString,") + #end + #end + #set($inputString = "$inputString }") + #end + + #if ($includePath) + #set($inputString = "$inputString, @@path@@:{") + #foreach($paramName in $allParams.path.keySet()) + #set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.path.get($paramName))@@") + #if($foreach.hasNext) + #set($inputString = "$inputString,") + #end + #end + #set($inputString = "$inputString }") + #end + + #set($requestContext = "%REQUESTCONTEXT%") + ## Check if the request context should be included as part of the execution input + #if($requestContext && !$requestContext.empty) + #set($inputString = "$inputString,") + #set($inputString = "$inputString @@requestContext@@: $requestContext") + #end + + #set($inputString = "$inputString}") + #set($inputString = $inputString.replaceAll("@@",'"')) + #set($len = $inputString.length() - 1) + "input": "{$util.escapeJavaScript($inputString.substring(1,$len))}" +} diff --git a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts index 0ed981690af3c..4d278eef9a500 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts @@ -312,6 +312,7 @@ export abstract class RestApiBase extends Resource implements IRestApi { /** * A human friendly name for this Rest API. Note that this is different from `restApiId`. + * @attribute */ public readonly restApiName: string; diff --git a/packages/@aws-cdk/aws-apigateway/lib/stepfunctions-api.ts b/packages/@aws-cdk/aws-apigateway/lib/stepfunctions-api.ts new file mode 100644 index 0000000000000..69addc0619383 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/stepfunctions-api.ts @@ -0,0 +1,153 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import { Construct } from 'constructs'; +import { RestApi, RestApiProps } from '.'; +import { RequestContext } from './integrations'; +import { StepFunctionsIntegration } from './integrations/stepfunctions'; +import { Model } from './model'; + +/** + * Properties for StepFunctionsRestApi + * + */ +export interface StepFunctionsRestApiProps extends RestApiProps { +/** + * The default State Machine that handles all requests from this API. + * + * This stateMachine will be used as a the default integration for all methods in + * this API, unless specified otherwise in `addMethod`. + */ + readonly stateMachine: sfn.IStateMachine; + + /** + * Which details of the incoming request must be passed onto the underlying state machine, + * such as, account id, user identity, request id, etc. The execution input will include a new key `requestContext`: + * + * { + * "body": {}, + * "requestContext": { + * "key": "value" + * } + * } + * + * @default - all parameters within request context will be set as false + */ + readonly requestContext?: RequestContext; + + /** + * Check if querystring is to be included inside the execution input. The execution input will include a new key `queryString`: + * + * { + * "body": {}, + * "querystring": { + * "key": "value" + * } + * } + * + * @default true + */ + readonly querystring?: boolean; + + /** + * Check if path is to be included inside the execution input. The execution input will include a new key `path`: + * + * { + * "body": {}, + * "path": { + * "resourceName": "resourceValue" + * } + * } + * + * @default true + */ + readonly path?: boolean; + + /** + * Check if header is to be included inside the execution input. The execution input will include a new key `headers`: + * + * { + * "body": {}, + * "headers": { + * "header1": "value", + * "header2": "value" + * } + * } + * @default false + */ + readonly headers?: boolean; +} + +/** + * Defines an API Gateway REST API with a Synchrounous Express State Machine as a proxy integration. + */ +export class StepFunctionsRestApi extends RestApi { + constructor(scope: Construct, id: string, props: StepFunctionsRestApiProps) { + if (props.defaultIntegration) { + throw new Error('Cannot specify "defaultIntegration" since Step Functions integration is automatically defined'); + } + + if ((props.stateMachine.node.defaultChild as sfn.CfnStateMachine).stateMachineType !== sfn.StateMachineType.EXPRESS) { + throw new Error('State Machine must be of type "EXPRESS". Please use StateMachineType.EXPRESS as the stateMachineType'); + } + + const stepfunctionsIntegration = StepFunctionsIntegration.startExecution(props.stateMachine, { + credentialsRole: role(scope, props), + requestContext: props.requestContext, + path: props.path?? true, + querystring: props.querystring?? true, + headers: props.headers, + }); + + super(scope, id, props); + + this.root.addMethod('ANY', stepfunctionsIntegration, { + methodResponses: methodResponse(), + }); + } +} + +/** + * Defines the IAM Role for API Gateway with required permissions + * to invoke a synchronous execution for the provided state machine + * + * @param scope + * @param props + * @returns Role - IAM Role + */ +function role(scope: Construct, props: StepFunctionsRestApiProps): iam.Role { + const roleName: string = 'StartSyncExecutionRole'; + const apiRole = new iam.Role(scope, roleName, { + assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), + }); + + props.stateMachine.grantStartSyncExecution(apiRole); + + return apiRole; +} + +/** + * Defines the method response modelfor each HTTP code response + * @returns methodResponse + */ +function methodResponse() { + return [ + { + statusCode: '200', + responseModels: { + 'application/json': Model.EMPTY_MODEL, + }, + }, + { + statusCode: '400', + responseModels: { + 'application/json': Model.ERROR_MODEL, + }, + }, + { + statusCode: '500', + responseModels: { + 'application/json': Model.ERROR_MODEL, + }, + }, + ]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/package.json b/packages/@aws-cdk/aws-apigateway/package.json index 8324c62c0b143..1c6e564c1496b 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -97,6 +97,7 @@ "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-s3-assets": "0.0.0", + "@aws-cdk/aws-stepfunctions": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", "constructs": "^3.3.69" @@ -113,6 +114,7 @@ "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-s3-assets": "0.0.0", + "@aws-cdk/aws-stepfunctions": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", "constructs": "^3.3.69" diff --git a/packages/@aws-cdk/aws-apigateway/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-apigateway/rosetta/default.ts-fixture index ae79983790fe4..bd7747f2a7a26 100644 --- a/packages/@aws-cdk/aws-apigateway/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/aws-apigateway/rosetta/default.ts-fixture @@ -8,6 +8,7 @@ import iam = require('@aws-cdk/aws-iam'); import s3 = require('@aws-cdk/aws-s3'); import ec2 = require('@aws-cdk/aws-ec2'); import logs = require('@aws-cdk/aws-logs'); +import stepfunctions = require('@aws-cdk/aws-stepfunctions'); class Fixture extends Stack { constructor(scope: Construct, id: string) { diff --git a/packages/@aws-cdk/aws-apigateway/rosetta/stepfunctions.ts-fixture b/packages/@aws-cdk/aws-apigateway/rosetta/stepfunctions.ts-fixture new file mode 100644 index 0000000000000..eb7728585bcde --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/rosetta/stepfunctions.ts-fixture @@ -0,0 +1,17 @@ +import { Construct } from 'constructs'; +import { Stack } from '@aws-cdk/core'; +import apigateway = require('@aws-cdk/aws-apigateway'); +import stepfunctions = require('@aws-cdk/aws-stepfunctions'); + +class Fixture extends Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + const machine: stepfunctions.IStateMachine = new stepfunctions.StateMachine(this, 'StateMachine', { + definition: new stepfunctions.Pass(this, 'PassState'), + stateMachineType: stepfunctions.StateMachineType.EXPRESS, + }); + + /// here + } +} diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.stepfunctions-api.expected.json b/packages/@aws-cdk/aws-apigateway/test/integ.stepfunctions-api.expected.json new file mode 100644 index 0000000000000..81e4a643c14e4 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integ.stepfunctions-api.expected.json @@ -0,0 +1,289 @@ +{ + "Resources": { + "StateMachineRoleB840431D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "states.", + { + "Ref": "AWS::Region" + }, + ".amazonaws.com" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "StateMachine2E01A3A5": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "RoleArn": { + "Fn::GetAtt": [ + "StateMachineRoleB840431D", + "Arn" + ] + }, + "DefinitionString": "{\"StartAt\":\"PassTask\",\"States\":{\"PassTask\":{\"Type\":\"Pass\",\"Result\":\"Hello\",\"End\":true}}}", + "StateMachineType": "EXPRESS" + }, + "DependsOn": [ + "StateMachineRoleB840431D" + ] + }, + "StartSyncExecutionRoleDE73CB90": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "StartSyncExecutionRoleDefaultPolicy5A5803F8": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "states:StartSyncExecution", + "Effect": "Allow", + "Resource": { + "Ref": "StateMachine2E01A3A5" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "StartSyncExecutionRoleDefaultPolicy5A5803F8", + "Roles": [ + { + "Ref": "StartSyncExecutionRoleDE73CB90" + } + ] + } + }, + "StepFunctionsRestApiC6E3E883": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "StepFunctionsRestApi" + } + }, + "StepFunctionsRestApiCloudWatchRoleB06ACDB9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + } + }, + "StepFunctionsRestApiAccountBD0CCC0E": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "StepFunctionsRestApiCloudWatchRoleB06ACDB9", + "Arn" + ] + } + }, + "DependsOn": [ + "StepFunctionsRestApiC6E3E883" + ] + }, + "StepFunctionsRestApiANY7699CA92": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "StepFunctionsRestApiC6E3E883", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "StepFunctionsRestApiC6E3E883" + }, + "AuthorizationType": "NONE", + "Integration": { + "Credentials": { + "Fn::GetAtt": [ + "StartSyncExecutionRoleDE73CB90", + "Arn" + ] + }, + "IntegrationHttpMethod": "POST", + "IntegrationResponses": [ + { + "ResponseTemplates": { + "application/json": "#set($inputRoot = $input.path('$'))\n#if($input.path('$.status').toString().equals(\"FAILED\"))\n#set($context.responseOverride.status = 500)\n{\n\"error\": \"$input.path('$.error')\"\n\"cause\": \"$input.path('$.cause')\"\n}\n#else\n$input.path('$.output')\n#end" + }, + "StatusCode": "200" + }, + { + "ResponseTemplates": { + "application/json": "{\n \"error\": \"Bad request!\"\n }" + }, + "SelectionPattern": "4\\d{2}", + "StatusCode": "400" + }, + { + "ResponseTemplates": { + "application/json": "\"error\": $input.path('$.error')" + }, + "SelectionPattern": "5\\d{2}", + "StatusCode": "500" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": { + "Fn::Join": [ + "", + [ + "## Velocity Template used for API Gateway request mapping template\n##\n## This template forwards the request body, header, path, and querystring\n## to the execution input of the state machine.\n##\n## \"@@\" is used here as a placeholder for '\"' to avoid using escape characters.\n\n#set($inputString = '')\n#set($includeHeaders = true)\n#set($includeQueryString = false)\n#set($includePath = false)\n#set($allParams = $input.params())\n{\n \"stateMachineArn\": \"", + { + "Ref": "StateMachine2E01A3A5" + }, + "\",\n\n #set($inputString = \"$inputString,@@body@@: $input.body\")\n\n #if ($includeHeaders)\n #set($inputString = \"$inputString, @@header@@:{\")\n #foreach($paramName in $allParams.header.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.header.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n \n #end\n\n #if ($includeQueryString)\n #set($inputString = \"$inputString, @@querystring@@:{\")\n #foreach($paramName in $allParams.querystring.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.querystring.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n #end\n\n #if ($includePath)\n #set($inputString = \"$inputString, @@path@@:{\")\n #foreach($paramName in $allParams.path.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.path.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n #end\n \n #set($requestContext = \"{@@accountId@@:@@$context.identity.accountId@@,@@userArn@@:@@$context.identity.userArn@@}\")\n ## Check if the request context should be included as part of the execution input\n #if($requestContext && !$requestContext.empty)\n #set($inputString = \"$inputString,\")\n #set($inputString = \"$inputString @@requestContext@@: $requestContext\")\n #end\n\n #set($inputString = \"$inputString}\")\n #set($inputString = $inputString.replaceAll(\"@@\",'\"'))\n #set($len = $inputString.length() - 1)\n \"input\": \"{$util.escapeJavaScript($inputString.substring(1,$len))}\"\n}\n" + ] + ] + } + }, + "Type": "AWS", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":states:action/StartSyncExecution" + ] + ] + } + }, + "MethodResponses": [ + { + "ResponseModels": { + "application/json": "Empty" + }, + "StatusCode": "200" + }, + { + "ResponseModels": { + "application/json": "Error" + }, + "StatusCode": "400" + }, + { + "ResponseModels": { + "application/json": "Error" + }, + "StatusCode": "500" + } + ] + } + }, + "deployment33381975b5dafda9a97138f301ea25da405640e8": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "StepFunctionsRestApiC6E3E883" + } + }, + "DependsOn": [ + "StepFunctionsRestApiANY7699CA92" + ] + }, + "stage0661E4AC": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "StepFunctionsRestApiC6E3E883" + }, + "DeploymentId": { + "Ref": "deployment33381975b5dafda9a97138f301ea25da405640e8" + }, + "StageName": "prod" + } + } + }, + "Outputs": { + "ApiEndpoint": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "StepFunctionsRestApiC6E3E883" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "stage0661E4AC" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.stepfunctions-api.ts b/packages/@aws-cdk/aws-apigateway/test/integ.stepfunctions-api.ts new file mode 100644 index 0000000000000..baa43fb1e97e3 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integ.stepfunctions-api.ts @@ -0,0 +1,52 @@ +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import * as apigw from '../lib'; + +/** + * Stack verification steps: + * * `curl -X POST 'https://.execute-api..amazonaws.com/prod' \ + * * -d '{"key":"Hello"}' -H 'Content-Type: application/json'` + * The above should return a "Hello" response + */ + +class StepFunctionsRestApiDeploymentStack extends cdk.Stack { + constructor(scope: Construct) { + super(scope, 'StepFunctionsRestApiDeploymentStack'); + + const passTask = new sfn.Pass(this, 'PassTask', { + result: { value: 'Hello' }, + }); + + const stateMachine = new sfn.StateMachine(this, 'StateMachine', { + definition: passTask, + stateMachineType: sfn.StateMachineType.EXPRESS, + }); + + const api = new apigw.StepFunctionsRestApi(this, 'StepFunctionsRestApi', { + deploy: false, + stateMachine: stateMachine, + headers: true, + path: false, + querystring: false, + requestContext: { + accountId: true, + userArn: true, + }, + }); + + api.deploymentStage = new apigw.Stage(this, 'stage', { + deployment: new apigw.Deployment(this, 'deployment', { + api, + }), + }); + + new cdk.CfnOutput(this, 'ApiEndpoint', { + value: api.url, + }); + } +} + +const app = new cdk.App(); +new StepFunctionsRestApiDeploymentStack(app); +app.synth(); diff --git a/packages/@aws-cdk/aws-apigateway/test/integrations/stepfunctions.test.ts b/packages/@aws-cdk/aws-apigateway/test/integrations/stepfunctions.test.ts new file mode 100644 index 0000000000000..c803fa974f7f6 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integrations/stepfunctions.test.ts @@ -0,0 +1,407 @@ +import '@aws-cdk/assert-internal/jest'; +import { stringLike, anything } from '@aws-cdk/assert-internal'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import { StateMachine, StateMachineType } from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import * as apigw from '../../lib'; + +describe('StepFunctionsIntegration', () => { + describe('startExecution', () => { + test('minimal setup', () => { + //GIVEN + const { stack, api, stateMachine } = givenSetup(); + + //WHEN + const integ = apigw.StepFunctionsIntegration.startExecution(stateMachine); + api.root.addMethod('GET', integ); + + //THEN + expect(stack).toHaveResource('AWS::ApiGateway::Method', { + ResourceId: { + 'Fn::GetAtt': [ + 'myrestapiBAC2BF45', + 'RootResourceId', + ], + }, + RestApiId: { + Ref: 'myrestapiBAC2BF45', + }, + AuthorizationType: 'NONE', + Integration: { + IntegrationHttpMethod: 'POST', + IntegrationResponses: getIntegrationResponse(), + Type: 'AWS', + Uri: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':apigateway:', + { + Ref: 'AWS::Region', + }, + ':states:action/StartSyncExecution', + ], + ], + }, + PassthroughBehavior: 'NEVER', + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + "## Velocity Template used for API Gateway request mapping template\n##\n## This template forwards the request body, header, path, and querystring\n## to the execution input of the state machine.\n##\n## \"@@\" is used here as a placeholder for '\"' to avoid using escape characters.\n\n#set($inputString = '')\n#set($includeHeaders = false)\n#set($includeQueryString = true)\n#set($includePath = true)\n#set($allParams = $input.params())\n{\n \"stateMachineArn\": \"", + { + Ref: 'StateMachine2E01A3A5', + }, + "\",\n\n #set($inputString = \"$inputString,@@body@@: $input.body\")\n\n #if ($includeHeaders)\n #set($inputString = \"$inputString, @@header@@:{\")\n #foreach($paramName in $allParams.header.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.header.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n \n #end\n\n #if ($includeQueryString)\n #set($inputString = \"$inputString, @@querystring@@:{\")\n #foreach($paramName in $allParams.querystring.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.querystring.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n #end\n\n #if ($includePath)\n #set($inputString = \"$inputString, @@path@@:{\")\n #foreach($paramName in $allParams.path.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.path.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n #end\n \n #set($requestContext = \"\")\n ## Check if the request context should be included as part of the execution input\n #if($requestContext && !$requestContext.empty)\n #set($inputString = \"$inputString,\")\n #set($inputString = \"$inputString @@requestContext@@: $requestContext\")\n #end\n\n #set($inputString = \"$inputString}\")\n #set($inputString = $inputString.replaceAll(\"@@\",'\"'))\n #set($len = $inputString.length() - 1)\n \"input\": \"{$util.escapeJavaScript($inputString.substring(1,$len))}\"\n}\n", + ], + ], + }, + }, + }, + }); + }); + + test('headers are NOT included by default', () => { + //GIVEN + const { stack, api, stateMachine } = givenSetup(); + + //WHEN + const integ = apigw.StepFunctionsIntegration.startExecution(stateMachine); + api.root.addMethod('GET', integ); + + expect(stack).toHaveResourceLike('AWS::ApiGateway::Method', { + Integration: { + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + stringLike('*includeHeaders = false*'), + { Ref: 'StateMachine2E01A3A5' }, + anything(), + ], + ], + }, + }, + }, + }); + }); + + test('headers are included when specified by the integration', () => { + //GIVEN + const { stack, api, stateMachine } = givenSetup(); + + //WHEN + const integ = apigw.StepFunctionsIntegration.startExecution(stateMachine, { + headers: true, + }); + api.root.addMethod('GET', integ); + + expect(stack).toHaveResourceLike('AWS::ApiGateway::Method', { + Integration: { + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + stringLike('*#set($includeHeaders = true)*'), + { Ref: 'StateMachine2E01A3A5' }, + anything(), + ], + ], + }, + }, + }, + }); + }); + + test('querystring and path are included by default', () => { + //GIVEN + const { stack, api, stateMachine } = givenSetup(); + + //WHEN + const integ = apigw.StepFunctionsIntegration.startExecution(stateMachine); + api.root.addMethod('GET', integ); + + expect(stack).toHaveResourceLike('AWS::ApiGateway::Method', { + Integration: { + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + stringLike('*#set($includeQueryString = true)*'), + { Ref: 'StateMachine2E01A3A5' }, + anything(), + ], + ], + }, + }, + }, + }); + + expect(stack).toHaveResourceLike('AWS::ApiGateway::Method', { + Integration: { + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + stringLike('*#set($includePath = true)*'), + { Ref: 'StateMachine2E01A3A5' }, + anything(), + ], + ], + }, + }, + }, + }); + }); + + test('querystring and path are false when specified by the integration', () => { + //GIVEN + const { stack, api, stateMachine } = givenSetup(); + + //WHEN + const integ = apigw.StepFunctionsIntegration.startExecution(stateMachine, { + querystring: false, + path: false, + }); + api.root.addMethod('GET', integ); + + expect(stack).toHaveResourceLike('AWS::ApiGateway::Method', { + Integration: { + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + stringLike('*#set($includeQueryString = false)*'), + { Ref: 'StateMachine2E01A3A5' }, + anything(), + ], + ], + }, + }, + }, + }); + + expect(stack).toHaveResourceLike('AWS::ApiGateway::Method', { + Integration: { + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + stringLike('*#set($includePath = false)*'), + { Ref: 'StateMachine2E01A3A5' }, + anything(), + ], + ], + }, + }, + }, + }); + }); + + test('request context is NOT included by default', () => { + //GIVEN + const { stack, api, stateMachine } = givenSetup(); + + //WHEN + const integ = apigw.StepFunctionsIntegration.startExecution(stateMachine, {}); + api.root.addMethod('GET', integ); + + expect(stack).toHaveResourceLike('AWS::ApiGateway::Method', { + Integration: { + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + anything(), + { Ref: 'StateMachine2E01A3A5' }, + stringLike('*#set($requestContext = \"\")*'), + ], + ], + }, + }, + }, + }); + }); + + test('request context is included when specified by the integration', () => { + //GIVEN + const { stack, api, stateMachine } = givenSetup(); + + //WHEN + const integ = apigw.StepFunctionsIntegration.startExecution(stateMachine, { + requestContext: { + accountId: true, + }, + }); + api.root.addMethod('GET', integ); + + expect(stack).toHaveResourceLike('AWS::ApiGateway::Method', { + Integration: { + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + anything(), + { Ref: 'StateMachine2E01A3A5' }, + stringLike('*#set($requestContext = \"{@@accountId@@:@@$context.identity.accountId@@}\"*'), + ], + ], + }, + }, + }, + }); + }); + + test('works for imported RestApi', () => { + const stack = new cdk.Stack(); + const api = apigw.RestApi.fromRestApiAttributes(stack, 'RestApi', { + restApiId: 'imported-rest-api-id', + rootResourceId: 'imported-root-resource-id', + }); + + const passTask = new sfn.Pass(stack, 'passTask', { + inputPath: '$.somekey', + }); + + const stateMachine: sfn.IStateMachine = new StateMachine(stack, 'StateMachine', { + definition: passTask, + stateMachineType: sfn.StateMachineType.EXPRESS, + }); + + api.root.addMethod('ANY', apigw.StepFunctionsIntegration.startExecution(stateMachine)); + + expect(stack).toHaveResource('AWS::ApiGateway::Method', { + ResourceId: 'imported-root-resource-id', + RestApiId: 'imported-rest-api-id', + }); + }); + + test('fingerprint is not computed when stateMachineName is not specified', () => { + // GIVEN + const stack = new cdk.Stack(); + const restapi = new apigw.RestApi(stack, 'RestApi'); + const method = restapi.root.addMethod('ANY'); + + const passTask = new sfn.Pass(stack, 'passTask', { + inputPath: '$.somekey', + }); + + const stateMachine: sfn.IStateMachine = new StateMachine(stack, 'StateMachine', { + definition: passTask, + stateMachineType: sfn.StateMachineType.EXPRESS, + }); + + const integ = apigw.StepFunctionsIntegration.startExecution(stateMachine); + + // WHEN + const bindResult = integ.bind(method); + + // THEN + expect(bindResult?.deploymentToken).toBeUndefined(); + }); + + test('bind works for integration with imported State Machine', () => { + // GIVEN + const stack = new cdk.Stack(); + const restapi = new apigw.RestApi(stack, 'RestApi'); + const method = restapi.root.addMethod('ANY'); + const stateMachine: sfn.IStateMachine = StateMachine.fromStateMachineArn(stack, 'MyStateMachine', 'arn:aws:states:region:account:stateMachine:MyStateMachine'); + const integration = apigw.StepFunctionsIntegration.startExecution(stateMachine, {}); + + // WHEN + const bindResult = integration.bind(method); + + // the deployment token should be defined since the function name + // should be a literal string. + expect(bindResult?.deploymentToken).toEqual('{"stateMachineName":"StateMachine-c8adc83b19e793491b1c6ea0fd8b46cd9f32e592fc"}'); + }); + + test('fails integration if State Machine is not of type EXPRESS', () => { + //GIVEN + const stack = new cdk.Stack(); + const restapi = new apigw.RestApi(stack, 'RestApi'); + const method = restapi.root.addMethod('ANY'); + const stateMachine: sfn.StateMachine = new StateMachine(stack, 'StateMachine', { + definition: new sfn.Pass(stack, 'passTask'), + stateMachineType: StateMachineType.STANDARD, + }); + const integration = apigw.StepFunctionsIntegration.startExecution(stateMachine); + + //WHEN + THEN + expect(() => integration.bind(method)) + .toThrow(/State Machine must be of type "EXPRESS". Please use StateMachineType.EXPRESS as the stateMachineType/); + }); + }); +}); + +function givenSetup() { + const stack = new cdk.Stack(); + const api = new apigw.RestApi(stack, 'my-rest-api'); + const passTask = new sfn.Pass(stack, 'passTask', { + inputPath: '$.somekey', + }); + + const stateMachine: sfn.IStateMachine = new StateMachine(stack, 'StateMachine', { + definition: passTask, + stateMachineType: sfn.StateMachineType.EXPRESS, + }); + + return { stack, api, stateMachine }; +} + +function getIntegrationResponse() { + const errorResponse = [ + { + SelectionPattern: '4\\d{2}', + StatusCode: '400', + ResponseTemplates: { + 'application/json': `{ + "error": "Bad request!" + }`, + }, + }, + { + SelectionPattern: '5\\d{2}', + StatusCode: '500', + ResponseTemplates: { + 'application/json': '"error": $input.path(\'$.error\')', + }, + }, + ]; + + const integResponse = [ + { + StatusCode: '200', + ResponseTemplates: { + 'application/json': [ + '#set($inputRoot = $input.path(\'$\'))', + '#if($input.path(\'$.status\').toString().equals("FAILED"))', + '#set($context.responseOverride.status = 500)', + '{', + '"error": "$input.path(\'$.error\')"', + '"cause": "$input.path(\'$.cause\')"', + '}', + '#else', + '$input.path(\'$.output\')', + '#end', + ].join('\n'), + }, + }, + ...errorResponse, + ]; + + return integResponse; +} diff --git a/packages/@aws-cdk/aws-apigateway/test/stepfunctions-api.test.ts b/packages/@aws-cdk/aws-apigateway/test/stepfunctions-api.test.ts new file mode 100644 index 0000000000000..30a1769b8965b --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/stepfunctions-api.test.ts @@ -0,0 +1,197 @@ +import '@aws-cdk/assert-internal/jest'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import { StateMachine } from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import * as apigw from '../lib'; + +describe('Step Functions api', () => { + test('StepFunctionsRestApi defines correct REST API resources', () => { + //GIVEN + const { stack, stateMachine } = givenSetup(); + + //WHEN + const api = whenCondition(stack, stateMachine); + + expect(() => { + api.root.addResource('not allowed'); + }).toThrow(); + + //THEN + expect(stack).toHaveResource('AWS::ApiGateway::Method', { + HttpMethod: 'ANY', + MethodResponses: getMethodResponse(), + AuthorizationType: 'NONE', + RestApiId: { + Ref: 'StepFunctionsRestApiC6E3E883', + }, + ResourceId: { + 'Fn::GetAtt': [ + 'StepFunctionsRestApiC6E3E883', + 'RootResourceId', + ], + }, + Integration: { + Credentials: { + 'Fn::GetAtt': [ + 'StartSyncExecutionRoleDE73CB90', + 'Arn', + ], + }, + IntegrationHttpMethod: 'POST', + IntegrationResponses: getIntegrationResponse(), + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + "## Velocity Template used for API Gateway request mapping template\n##\n## This template forwards the request body, header, path, and querystring\n## to the execution input of the state machine.\n##\n## \"@@\" is used here as a placeholder for '\"' to avoid using escape characters.\n\n#set($inputString = '')\n#set($includeHeaders = false)\n#set($includeQueryString = true)\n#set($includePath = true)\n#set($allParams = $input.params())\n{\n \"stateMachineArn\": \"", + { + Ref: 'StateMachine2E01A3A5', + }, + "\",\n\n #set($inputString = \"$inputString,@@body@@: $input.body\")\n\n #if ($includeHeaders)\n #set($inputString = \"$inputString, @@header@@:{\")\n #foreach($paramName in $allParams.header.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.header.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n \n #end\n\n #if ($includeQueryString)\n #set($inputString = \"$inputString, @@querystring@@:{\")\n #foreach($paramName in $allParams.querystring.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.querystring.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n #end\n\n #if ($includePath)\n #set($inputString = \"$inputString, @@path@@:{\")\n #foreach($paramName in $allParams.path.keySet())\n #set($inputString = \"$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.path.get($paramName))@@\")\n #if($foreach.hasNext)\n #set($inputString = \"$inputString,\")\n #end\n #end\n #set($inputString = \"$inputString }\")\n #end\n \n #set($requestContext = \"\")\n ## Check if the request context should be included as part of the execution input\n #if($requestContext && !$requestContext.empty)\n #set($inputString = \"$inputString,\")\n #set($inputString = \"$inputString @@requestContext@@: $requestContext\")\n #end\n\n #set($inputString = \"$inputString}\")\n #set($inputString = $inputString.replaceAll(\"@@\",'\"'))\n #set($len = $inputString.length() - 1)\n \"input\": \"{$util.escapeJavaScript($inputString.substring(1,$len))}\"\n}\n", + ], + ], + }, + }, + Type: 'AWS', + Uri: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':apigateway:', + { + Ref: 'AWS::Region', + }, + ':states:action/StartSyncExecution', + ], + ], + }, + PassthroughBehavior: 'NEVER', + }, + }); + }); + + test('fails if options.defaultIntegration is set', () => { + //GIVEN + const { stack, stateMachine } = givenSetup(); + + const httpURL: string = 'https://foo/bar'; + + //WHEN & THEN + expect(() => new apigw.StepFunctionsRestApi(stack, 'StepFunctionsRestApi', { + stateMachine: stateMachine, + defaultIntegration: new apigw.HttpIntegration(httpURL), + })).toThrow(/Cannot specify \"defaultIntegration\" since Step Functions integration is automatically defined/); + + }); + + test('fails if State Machine is not of type EXPRESS', () => { + //GIVEN + const stack = new cdk.Stack(); + + const passTask = new sfn.Pass(stack, 'passTask', { + inputPath: '$.somekey', + }); + + const stateMachine: sfn.IStateMachine = new StateMachine(stack, 'StateMachine', { + definition: passTask, + stateMachineType: sfn.StateMachineType.STANDARD, + }); + + //WHEN & THEN + expect(() => new apigw.StepFunctionsRestApi(stack, 'StepFunctionsRestApi', { + stateMachine: stateMachine, + })).toThrow(/State Machine must be of type "EXPRESS". Please use StateMachineType.EXPRESS as the stateMachineType/); + }); +}); + +function givenSetup() { + const stack = new cdk.Stack(); + + const passTask = new sfn.Pass(stack, 'passTask', { + inputPath: '$.somekey', + }); + + const stateMachine: sfn.IStateMachine = new StateMachine(stack, 'StateMachine', { + definition: passTask, + stateMachineType: sfn.StateMachineType.EXPRESS, + }); + + return { stack, stateMachine }; +} + +function whenCondition(stack:cdk.Stack, stateMachine: sfn.IStateMachine) { + const api = new apigw.StepFunctionsRestApi(stack, 'StepFunctionsRestApi', { stateMachine: stateMachine }); + return api; +} + +function getMethodResponse() { + return [ + { + StatusCode: '200', + ResponseModels: { + 'application/json': 'Empty', + }, + }, + { + StatusCode: '400', + ResponseModels: { + 'application/json': 'Error', + }, + }, + { + StatusCode: '500', + ResponseModels: { + 'application/json': 'Error', + }, + }, + ]; +} + +function getIntegrationResponse() { + const errorResponse = [ + { + SelectionPattern: '4\\d{2}', + StatusCode: '400', + ResponseTemplates: { + 'application/json': `{ + "error": "Bad request!" + }`, + }, + }, + { + SelectionPattern: '5\\d{2}', + StatusCode: '500', + ResponseTemplates: { + 'application/json': '"error": $input.path(\'$.error\')', + }, + }, + ]; + + const integResponse = [ + { + StatusCode: '200', + ResponseTemplates: { + 'application/json': [ + '#set($inputRoot = $input.path(\'$\'))', + '#if($input.path(\'$.status\').toString().equals("FAILED"))', + '#set($context.responseOverride.status = 500)', + '{', + '"error": "$input.path(\'$.error\')"', + '"cause": "$input.path(\'$.cause\')"', + '}', + '#else', + '$input.path(\'$.output\')', + '#end', + ].join('\n'), + }, + }, + ...errorResponse, + ]; + + return integResponse; +} diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts b/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts index a75a5f1843992..30eb7dd7c550b 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts @@ -164,6 +164,18 @@ abstract class StateMachineBase extends Resource implements IStateMachine { }); } + /** + * Grant the given identity permissions to start a synchronous execution of + * this state machine. + */ + public grantStartSyncExecution(identity: iam.IGrantable): iam.Grant { + return iam.Grant.addToPrincipal({ + grantee: identity, + actions: ['states:StartSyncExecution'], + resourceArns: [this.stateMachineArn], + }); + } + /** * Grant the given identity permissions to read results from state * machine. @@ -505,6 +517,14 @@ export interface IStateMachine extends IResource, iam.IGrantable { */ grantStartExecution(identity: iam.IGrantable): iam.Grant; + /** + * Grant the given identity permissions to start a synchronous execution of + * this state machine. + * + * @param identity The principal + */ + grantStartSyncExecution(identity: iam.IGrantable): iam.Grant; + /** * Grant the given identity read permissions for this state machine * diff --git a/packages/@aws-cdk/aws-stepfunctions/test/state-machine-resources.test.ts b/packages/@aws-cdk/aws-stepfunctions/test/state-machine-resources.test.ts index 78448372842b9..e80201f5d4adf 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/state-machine-resources.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/state-machine-resources.test.ts @@ -196,6 +196,36 @@ describe('State Machine Resources', () => { }), + test('Created state machine can grant start sync execution to a role', () => { + // GIVEN + const stack = new cdk.Stack(); + const task = new FakeTask(stack, 'Task'); + const stateMachine = new stepfunctions.StateMachine(stack, 'StateMachine', { + definition: task, + stateMachineType: stepfunctions.StateMachineType.EXPRESS, + }); + const role = new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + }); + + // WHEN + stateMachine.grantStartSyncExecution(role); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([Match.objectLike({ + Action: 'states:StartSyncExecution', + Effect: 'Allow', + Resource: { + Ref: 'StateMachine2E01A3A5', + }, + })]), + }, + }); + + }), + test('Created state machine can grant read access to a role', () => { // GIVEN const stack = new cdk.Stack();