diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index 4789a8cc62410..1cbe0556e6bb2 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,31 @@ 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` construct makes this easy and also sets up input, output and error mapping. + +The following code defines a REST API that routes all requests to the specified AWS StepFunctions state machine: + +```ts +const stateMachine = new stepFunctions.StateMachine(this, 'StateMachine', ...); +new apigateway.StepFunctionsRestApi(this, 'StepFunctionsRestApi', { + stateMachine: stateMachine, +}); +``` + +You can add requestContext (similar to input requestContext from lambda input) to the input. The 'requestContext' parameter includes account ID, user identity, etc. that can be used by customers that want to know the identity of authorized users on the state machine side. The following code defines a REST API like above but also adds 'requestContext' to the input of the State Machine: + +```ts +const stateMachine = new stepFunctions.StateMachine(this, 'StateMachine', ...); +new apigateway.StepFunctionsRestApi(this, 'StepFunctionsRestApi', { + stateMachine: stateMachine, + includeRequestContext: true, +}); +``` + ### 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..6c83317a86539 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/integrations/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/integrations/index.ts @@ -2,3 +2,4 @@ export * from './aws'; export * from './lambda'; export * from './http'; export * from './mock'; +export * from './stepfunctions'; 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..cc4e493b20c2b --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/integrations/stepfunctions.ts @@ -0,0 +1,166 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import { Token } from '@aws-cdk/core'; +import { IntegrationConfig, IntegrationOptions, PassthroughBehavior } from '../integration'; +import { Method } from '../method'; +import { AwsIntegration } from './aws'; + +/** + * Options when configuring Step Functions integration with Rest API + */ +export interface StepFunctionsIntegrationOptions extends IntegrationOptions { + /** + * Use proxy integration or normal (request/response mapping) integration. + * + * @default false + */ + readonly proxy?: boolean; + + /** + * Check if cors is enabled + * @default false + */ + readonly corsEnabled?: boolean; + + /** + * Check if requestContext is enabled + * If enabled, requestContext is passed into the input of the State Machine. + * @default false + */ + readonly includeRequestContext?: boolean; + +} +/** + * Integrates a Synchronous Express State Machine from AWS Step Functions to an API Gateway method. + * + * @example + * + * const stateMachine = new sfn.StateMachine(this, 'MyStateMachine', ...); + * api.addMethod('GET', new StepFunctionsIntegration(stateMachine)); + */ +export class StepFunctionsIntegration extends AwsIntegration { + private readonly stateMachine: sfn.IStateMachine; + + constructor(stateMachine: sfn.IStateMachine, options: StepFunctionsIntegrationOptions = { }) { + + const integResponse = getIntegrationResponse(); + const requestTemplate = getRequestTemplates(stateMachine, options.includeRequestContext); + super({ + proxy: options.proxy, + service: 'states', + action: 'StartSyncExecution', + options: { + credentialsRole: options.credentialsRole, + integrationResponses: integResponse, + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: requestTemplate, + }, + }); + + 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) { + //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-' + (String(this.stateMachine.stack.node.addr).substring(0, 8)); + } + + let deploymentToken; + + if (stateMachineName !== undefined && !Token.isUnresolved(stateMachineName)) { + deploymentToken = JSON.stringify({ stateMachineName }); + } + return { + ...bindResult, + deploymentToken, + }; + + } +} + +function getIntegrationResponse() { + const errorResponse = [ + { + selectionPattern: '4\\d{2}', + statusCode: '400', + responseTemplates: { + 'application/json': `{ + "error": "Bad input!" + }`, + }, + }, + { + 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`, + }, + }, + ...errorResponse, + ]; + + return integResponse; +} + +function getRequestTemplates(stateMachine: sfn.IStateMachine, includeRequestContext: boolean | undefined) { + const templateString = getTemplateString(stateMachine, includeRequestContext); + + const requestTemplate: { [contentType:string] : string } = + { + 'application/json': templateString, + }; + + return requestTemplate; +} + +function getTemplateString(stateMachine: sfn.IStateMachine, includeRequestContext: boolean | undefined): string { + let templateString: string; + const requestContextStr:string = '"body": $util.escapeJavaScript($input.json(\'$\')),"requestContext": {"accountId" : "$context.identity.accountId","apiId" : "$context.apiId","apiKey" : "$context.identity.apiKey","authorizerPrincipalId" : "$context.authorizer.principalId","caller" : "$context.identity.caller","cognitoAuthenticationProvider" : "$context.identity.cognitoAuthenticationProvider","cognitoAuthenticationType" : "$context.identity.cognitoAuthenticationType","cognitoIdentityId" : "$context.identity.cognitoIdentityId","cognitoIdentityPoolId" : "$context.identity.cognitoIdentityPoolId","httpMethod" : "$context.httpMethod","stage" : "$context.stage","sourceIp" : "$context.identity.sourceIp","user" : "$context.identity.user","userAgent" : "$context.identity.userAgent","userArn" : "$context.identity.userArn","requestId" : "$context.requestId","resourceId" : "$context.resourceId","resourcePath" : "$context.resourcePath"}'; + const search = '"'; + const replaceWith = '\\"'; + if (typeof includeRequestContext === 'boolean' && includeRequestContext === true) { + templateString = ` + #set($allParams = $input.params()) + { + "input": "{${requestContextStr.split(search).join(replaceWith)}}", + "stateMachineArn": "${stateMachine.stateMachineArn}" + }`; + } else { + templateString = ` + #set($inputRoot = $input.path('$')) { + "input": "{\\"body\\": $util.escapeJavaScript($input.json('$'))}", + "stateMachineArn": "${stateMachine.stateMachineArn}" + }`; + } + return templateString; +} 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..8701d3f6655bd --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/stepfunctions-api.ts @@ -0,0 +1,132 @@ +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 { StepFunctionsIntegration } from './integrations/stepFunctions'; +import { Model } from './model'; + +/** + * Propeties 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; + + /** + * If true, route all requests to the State Machine + * + * If set to false, you will need to explicitly define the API model using + * `addResource` and `addMethod` (or `addProxy`). + * + * Note: Proxy is not yet supported for State Machines + * + * @default false + */ + readonly proxy?: boolean; + + /** + * Check if requestContext is enabled + * If enabled, requestContext is passed into the input of the State Machine. + * @default false + */ + readonly includeRequestContext?: 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 apiRole = getRole(scope, props); + const methodResp = getMethodResponse(); + + let corsEnabled; + + if (props.defaultCorsPreflightOptions !== undefined) { + corsEnabled = true; + } else { + corsEnabled = false; + } + + super(scope, id, { + defaultIntegration: new StepFunctionsIntegration(props.stateMachine, { + credentialsRole: apiRole, + proxy: false, //proxy not avaialble for Step Functions yet + corsEnabled: corsEnabled, + includeRequestContext: props.includeRequestContext, + }), + ...props, + }); + + if (!corsEnabled) { + this.root.addMethod('ANY', new StepFunctionsIntegration(props.stateMachine, { + credentialsRole: apiRole, + includeRequestContext: props.includeRequestContext, + }), { + methodResponses: [ + ...methodResp, + ], + }); + } + } +} + +function getRole(scope: Construct, props: StepFunctionsRestApiProps): iam.Role { + const apiName: string = props.stateMachine + '-apiRole'; + const apiRole = new iam.Role(scope, apiName, { + assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), + }); + + apiRole.attachInlinePolicy( + new iam.Policy(scope, 'AllowStartSyncExecution', { + statements: [ + new iam.PolicyStatement({ + actions: ['states:StartSyncExecution'], + effect: iam.Effect.ALLOW, + resources: [props.stateMachine.stateMachineArn], + }), + ], + }), + ); + + return apiRole; +} + +function getMethodResponse() { + const methodResp = [ + { + statusCode: '200', + responseModels: { + 'application/json': Model.EMPTY_MODEL, + }, + }, + { + statusCode: '400', + responseModels: { + 'application/json': Model.ERROR_MODEL, + }, + }, + { + statusCode: '500', + responseModels: { + 'application/json': Model.ERROR_MODEL, + }, + }, + ]; + + return methodResp; +} \ 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 132082167f0f6..2b0793b3adf95 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -92,6 +92,7 @@ "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", + "@aws-cdk/aws-stepfunctions": "0.0.0", "constructs": "^3.3.69" }, "homepage": "https://github.com/aws/aws-cdk", @@ -108,6 +109,7 @@ "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", + "@aws-cdk/aws-stepfunctions": "0.0.0", "constructs": "^3.3.69" }, "engines": { @@ -318,6 +320,7 @@ "attribute-tag:@aws-cdk/aws-apigateway.RequestAuthorizer.authorizerArn", "attribute-tag:@aws-cdk/aws-apigateway.TokenAuthorizer.authorizerArn", "attribute-tag:@aws-cdk/aws-apigateway.RestApi.restApiName", + "attribute-tag:@aws-cdk/aws-apigateway.StepFunctionsRestApi.restApiName", "attribute-tag:@aws-cdk/aws-apigateway.SpecRestApi.restApiName", "attribute-tag:@aws-cdk/aws-apigateway.LambdaRestApi.restApiName", "from-method:@aws-cdk/aws-apigateway.Stage", diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.expected.json b/packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.expected.json new file mode 100644 index 0000000000000..9531d655158fe --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.expected.json @@ -0,0 +1,261 @@ +{ + "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" + ] + }, + "StepFunctionsRestApiDeploymentStackStateMachineapiRoleE9B057CB": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "AllowStartSyncExecutionE0A8041C": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "states:StartSyncExecution", + "Effect": "Allow", + "Resource": { + "Ref": "StateMachine2E01A3A5" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "AllowStartSyncExecutionE0A8041C", + "Roles": [ + { + "Ref": "StepFunctionsRestApiDeploymentStackStateMachineapiRoleE9B057CB" + } + ] + } + }, + "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": [ + "StepFunctionsRestApiDeploymentStackStateMachineapiRoleE9B057CB", + "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 input!\"\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": [ + "", + [ + "\n #set($inputRoot = $input.path('$')) {\n \"input\": \"{\\\"body\\\": $util.escapeJavaScript($input.json('$'))}\",\n \"stateMachineArn\": \"", + { + "Ref": "StateMachine2E01A3A5" + }, + "\"\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" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.ts b/packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.ts new file mode 100644 index 0000000000000..14719e9ef901a --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.ts @@ -0,0 +1,33 @@ +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import * as apigw from '../lib'; + +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, + }); + + api.deploymentStage = new apigw.Stage(this, 'stage', { + deployment: new apigw.Deployment(this, 'deployment', { + api, + }), + }); + } +} + +const app = new cdk.App(); +new StepFunctionsRestApiDeploymentStack(app); 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..d42ea9c8bdb2f --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integrations/stepFunctions.test.ts @@ -0,0 +1,247 @@ +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'; + +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 input!" + }`, + }, + }, + { + 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`, + }, + }, + ...errorResponse, + ]; + + return integResponse; +} + +describe('StepFunctions', () => { + test('minimal setup', () => { + //GIVEN + const { stack, api, stateMachine } = givenSetup(); + + //WHEN + const integ = new apigw.StepFunctionsIntegration(stateMachine); + api.root.addMethod('GET', integ); + + //THEN + expect(stack).toHaveResource('AWS::ApiGateway::Method', { + // HttpMethod: 'GET', + 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': [ + '', + [ + "\n #set($inputRoot = $input.path('$')) {\n \"input\": \"{\\\"body\\\": $util.escapeJavaScript($input.json('$'))}\",\n \"stateMachineArn\": \"", + { + Ref: 'StateMachine2E01A3A5', + }, + '"\n }', + ], + ], + }, + }, + }, + }); + }); + + 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', new apigw.StepFunctionsIntegration(stateMachine)); + + expect(stack).toHaveResource('AWS::ApiGateway::Method', { + HttpMethod: 'ANY', + ResourceId: 'imported-root-resource-id', + RestApiId: 'imported-rest-api-id', + AuthorizationType: 'NONE', + Integration: { + 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 input!"\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': [ + '', + [ + "\n #set($inputRoot = $input.path('$')) {\n \"input\": \"{\\\"body\\\": $util.escapeJavaScript($input.json('$'))}\",\n \"stateMachineArn\": \"", + { + Ref: 'StateMachine2E01A3A5', + }, + '"\n }', + ], + ], + }, + }, + Type: 'AWS', + Uri: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':apigateway:', + { + Ref: 'AWS::Region', + }, + ':states:action/StartSyncExecution', + ], + ], + }, + }, + }); + }); + + 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 = new apigw.StepFunctionsIntegration(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 = new apigw.StepFunctionsIntegration(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-c8adc83b"}'); + }); +}); 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..d363c4c615b79 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/stepFunctions-api.test.ts @@ -0,0 +1,329 @@ +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'; + +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() { + const methodResp = [ + { + StatusCode: '200', + ResponseModels: { + 'application/json': 'Empty', + }, + }, + { + StatusCode: '400', + ResponseModels: { + 'application/json': 'Error', + }, + }, + { + StatusCode: '500', + ResponseModels: { + 'application/json': 'Error', + }, + }, + ]; + + return methodResp; +} + +function getIntegrationResponse() { + const errorResponse = [ + { + SelectionPattern: '4\\d{2}', + StatusCode: '400', + ResponseTemplates: { + 'application/json': `{ + "error": "Bad input!" + }`, + }, + }, + { + 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`, + }, + }, + ...errorResponse, + ]; + + return integResponse; +} + +describe('Step Functions api', () => { + test('StepFunctionsRestApi defines correct REST API resouces', () => { + //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': [ + 'DefaultStateMachineapiRole1F29ACEB', + 'Arn', + ], + }, + IntegrationHttpMethod: 'POST', + IntegrationResponses: getIntegrationResponse(), + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + "\n #set($inputRoot = $input.path('$')) {\n \"input\": \"{\\\"body\\\": $util.escapeJavaScript($input.json('$'))}\",\n \"stateMachineArn\": \"", + { + Ref: 'StateMachine2E01A3A5', + }, + '"\n }', + ], + ], + }, + }, + Type: 'AWS', + Uri: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':apigateway:', + { + Ref: 'AWS::Region', + }, + ':states:action/StartSyncExecution', + ], + ], + }, + PassthroughBehavior: 'NEVER', + }, + }); + }); + + test('StepFunctionsRestApi defines correct REST API resouces with includeRequestContext set to true', () => { + //GIVEN + const { stack, stateMachine } = givenSetup(); + + //WHEN + const api = new apigw.StepFunctionsRestApi(stack, + 'StepFunctionsRestApi', { + stateMachine: stateMachine, + includeRequestContext: true, + }); + + 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': [ + 'DefaultStateMachineapiRole1F29ACEB', + 'Arn', + ], + }, + IntegrationHttpMethod: 'POST', + IntegrationResponses: getIntegrationResponse(), + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + "\n #set($allParams = $input.params())\n {\n \"input\": \"{\\\"body\\\": $util.escapeJavaScript($input.json('$')),\\\"requestContext\\\": {\\\"accountId\\\" : \\\"$context.identity.accountId\\\",\\\"apiId\\\" : \\\"$context.apiId\\\",\\\"apiKey\\\" : \\\"$context.identity.apiKey\\\",\\\"authorizerPrincipalId\\\" : \\\"$context.authorizer.principalId\\\",\\\"caller\\\" : \\\"$context.identity.caller\\\",\\\"cognitoAuthenticationProvider\\\" : \\\"$context.identity.cognitoAuthenticationProvider\\\",\\\"cognitoAuthenticationType\\\" : \\\"$context.identity.cognitoAuthenticationType\\\",\\\"cognitoIdentityId\\\" : \\\"$context.identity.cognitoIdentityId\\\",\\\"cognitoIdentityPoolId\\\" : \\\"$context.identity.cognitoIdentityPoolId\\\",\\\"httpMethod\\\" : \\\"$context.httpMethod\\\",\\\"stage\\\" : \\\"$context.stage\\\",\\\"sourceIp\\\" : \\\"$context.identity.sourceIp\\\",\\\"user\\\" : \\\"$context.identity.user\\\",\\\"userAgent\\\" : \\\"$context.identity.userAgent\\\",\\\"userArn\\\" : \\\"$context.identity.userArn\\\",\\\"requestId\\\" : \\\"$context.requestId\\\",\\\"resourceId\\\" : \\\"$context.resourceId\\\",\\\"resourcePath\\\" : \\\"$context.resourcePath\\\"}}\",\n \"stateMachineArn\": \"", + { + Ref: 'StateMachine2E01A3A5', + }, + '"\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/); + + }); + + test('StepFunctionsRestApi defines a REST API with CORS enabled', () => { + const { stack, stateMachine } = givenSetup(); + + //WHEN + new apigw.StepFunctionsRestApi(stack, 'StepFunctionsRestApi', { + stateMachine: stateMachine, + defaultCorsPreflightOptions: { + allowOrigins: ['https://aws.amazon.com'], + allowMethods: ['GET', 'PUT'], + }, + }); + + //THEN + expect(stack).toHaveResource('AWS::ApiGateway::Method', { + HttpMethod: 'OPTIONS', + ResourceId: { + 'Fn::GetAtt': [ + 'StepFunctionsRestApiC6E3E883', + 'RootResourceId', + ], + }, + RestApiId: { + Ref: 'StepFunctionsRestApiC6E3E883', + }, + AuthorizationType: 'NONE', + Integration: { + IntegrationResponses: [ + { + ResponseParameters: { + 'method.response.header.Access-Control-Allow-Headers': "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", + 'method.response.header.Access-Control-Allow-Origin': "'https://aws.amazon.com'", + 'method.response.header.Vary': "'Origin'", + 'method.response.header.Access-Control-Allow-Methods': "'GET,PUT'", + }, + StatusCode: '204', + }, + ], + RequestTemplates: { + 'application/json': '{ statusCode: 200 }', + }, + Type: 'MOCK', + }, + MethodResponses: [ + { + ResponseParameters: { + 'method.response.header.Access-Control-Allow-Headers': true, + 'method.response.header.Access-Control-Allow-Origin': true, + 'method.response.header.Vary': true, + 'method.response.header.Access-Control-Allow-Methods': true, + }, + StatusCode: '204', + }, + ], + }); + }); +});