From d3b6f7457c62faf1c18bbb21a5086834fc38b64c Mon Sep 17 00:00:00 2001 From: Saqib Dhuka Date: Wed, 6 Oct 2021 17:45:05 +0000 Subject: [PATCH] StepFunctionsRestApi implemented along with unit and integration testing. Fixed Integration test and generated expected json for stepFunctionsRestApi Stack deployment. closes aws#15081. --- packages/@aws-cdk/aws-apigateway/lib/index.ts | 1 + .../aws-apigateway/lib/integrations/index.ts | 1 + .../lib/integrations/stepFunctions.ts | 158 +++++++++++ .../aws-apigateway/lib/stepFunctions-api.ts | 124 +++++++++ packages/@aws-cdk/aws-apigateway/package.json | 7 +- ...unctions-api.deploymentStack.expected.json | 261 ++++++++++++++++++ ...integ.stepFunctions-api.deploymentStack.ts | 33 +++ .../test/integrations/stepFunctions.test.ts | 247 +++++++++++++++++ .../test/stepFunctions-api.test.ts | 239 ++++++++++++++++ 9 files changed, 1069 insertions(+), 2 deletions(-) create mode 100644 packages/@aws-cdk/aws-apigateway/lib/integrations/stepFunctions.ts create mode 100644 packages/@aws-cdk/aws-apigateway/lib/stepFunctions-api.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.expected.json create mode 100644 packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/integrations/stepFunctions.test.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/stepFunctions-api.test.ts diff --git a/packages/@aws-cdk/aws-apigateway/lib/index.ts b/packages/@aws-cdk/aws-apigateway/lib/index.ts index 4c288b27f4160..307f334dbec25 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..6edfafcb29684 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..9c8eeb33ccd3f --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/integrations/stepFunctions.ts @@ -0,0 +1,158 @@ +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'; + +/** + * StepFunctionsIntegrationOptions + */ +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; + +} +/** + * Integrates a Synchronous Express State Machine from AWS Step Functions to an API Gateway method. + * + * @example + * + * const handler = new sfn.StateMachine(this, 'MyStateMachine', ...); + * api.addMethod('GET', new StepFunctionsIntegration(handler)); + * + */ + +export class StepFunctionsIntegration extends AwsIntegration { + private readonly handler: sfn.IStateMachine; + + constructor(handler: sfn.IStateMachine, options: StepFunctionsIntegrationOptions = { }) { + + const integResponse = getIntegrationResponse(); + const requestTemplate = getRequestTemplates(handler); + + if (options.corsEnabled) { + super({ + proxy: options.proxy, + service: 'states', + action: 'StartSyncExecution', + options, + }); + } else { + super({ + proxy: options.proxy, + service: 'states', + action: 'StartSyncExecution', + options: { + credentialsRole: options.credentialsRole, + integrationResponses: integResponse, + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: requestTemplate, + }, + }); + } + + this.handler = handler; + } + + public bind(method: Method): IntegrationConfig { + const bindResult = super.bind(method); + const principal = new iam.ServicePrincipal('apigateway.amazonaws.com'); + + this.handler.grantExecution(principal, 'states:StartSyncExecution'); + + let stateMachineName; + + if (this.handler 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.handler.node.defaultChild as sfn.CfnStateMachine).stateMachineName; + } else { + stateMachineName = 'StateMachine-' + (String(this.handler.stack.node.addr).substring(0, 8)); + } + + let deploymentToken; + + if (!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(handler: sfn.IStateMachine) { + const templateString = getTemplateString(handler); + + const requestTemplate: { [contenType:string] : string } = + { + 'application/json': templateString, + }; + + return requestTemplate; +} + +function getTemplateString(handler: sfn.IStateMachine): string { + const templateString: string = ` + #set($inputRoot = $input.path('$')) { + "input": "$util.escapeJavaScript($input.json(\'$\'))", + "stateMachineArn": "${handler.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..f341788d6e0db --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/stepFunctions-api.ts @@ -0,0 +1,124 @@ +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'; + +/** + * StepFunctionsRestApiProps + */ +export interface StepFunctionsRestApiProps extends RestApiProps { +/** + * The default State Machine that handles all requests from this API. + * + * This handler will be used as a the default integration for all methods in + * this API, unless specified otherwise in `addMethod`. + */ + readonly handler: 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`). + * + * @default true + */ + readonly proxy?: boolean; + + /** + * Rest API props options + * @default - no options. + * + */ + readonly options?: RestApiProps; +} + +/** + * 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.options && props.options.defaultIntegration) || props.defaultIntegration) { + throw new Error('Cannot specify "defaultIntegration" since Step Functions integration is automatically defined'); + } + + 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.handler, { + credentialsRole: apiRole, + proxy: false, //proxy not avaialble for Step Functions yet + corsEnabled: corsEnabled, + }), + ...props.options, + ...props, + }); + + this.root.addMethod('ANY', new StepFunctionsIntegration(props.handler, { + credentialsRole: apiRole, + }), { + methodResponses: [ + ...methodResp, + ], + }); + } +} + +function getRole(scope: Construct, props: StepFunctionsRestApiProps): iam.Role { + const apiName: string = props.handler + '-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.handler.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..2c1a9ff0d7944 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -92,7 +92,8 @@ "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", - "constructs": "^3.3.69" + "constructs": "^3.3.69", + "@aws-cdk/aws-stepfunctions": "0.0.0" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { @@ -108,7 +109,8 @@ "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", - "constructs": "^3.3.69" + "constructs": "^3.3.69", + "@aws-cdk/aws-stepfunctions": "0.0.0" }, "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" @@ -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..27a4d9cd81f08 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integ.stepFunctions-api.deploymentStack.expected.json @@ -0,0 +1,261 @@ +{ + "Resources": { + "handlerRole5448137F": { + "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" + } + } + }, + "handlerE1533BD5": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "RoleArn": { + "Fn::GetAtt": [ + "handlerRole5448137F", + "Arn" + ] + }, + "DefinitionString": "{\"StartAt\":\"PassTask\",\"States\":{\"PassTask\":{\"Type\":\"Pass\",\"Result\":\"Hello\",\"End\":true}}}", + "StateMachineType": "EXPRESS" + }, + "DependsOn": [ + "handlerRole5448137F" + ] + }, + "StepFunctionsRestApiDeploymentStackhandlerapiRole7F875839": { + "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": "handlerE1533BD5" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "AllowStartSyncExecutionE0A8041C", + "Roles": [ + { + "Ref": "StepFunctionsRestApiDeploymentStackhandlerapiRole7F875839" + } + ] + } + }, + "StepFunctionsrestapi3C602481": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "StepFunctions-rest-api" + } + }, + "StepFunctionsrestapiCloudWatchRoleE6FF5D25": { + "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" + ] + ] + } + ] + } + }, + "StepFunctionsrestapiAccountFE3E2047": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "StepFunctionsrestapiCloudWatchRoleE6FF5D25", + "Arn" + ] + } + }, + "DependsOn": [ + "StepFunctionsrestapi3C602481" + ] + }, + "StepFunctionsrestapiANY2D495B43": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "StepFunctionsrestapi3C602481", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "StepFunctionsrestapi3C602481" + }, + "AuthorizationType": "NONE", + "Integration": { + "Credentials": { + "Fn::GetAtt": [ + "StepFunctionsRestApiDeploymentStackhandlerapiRole7F875839", + "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\": \"$util.escapeJavaScript($input.json('$'))\",\n \"stateMachineArn\": \"", + { + "Ref": "handlerE1533BD5" + }, + "\"\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" + } + ] + } + }, + "deployment333819757133bf57c3fc3772a18d36df02381e2e": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "StepFunctionsrestapi3C602481" + } + }, + "DependsOn": [ + "StepFunctionsrestapiANY2D495B43" + ] + }, + "stage0661E4AC": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "StepFunctionsrestapi3C602481" + }, + "DeploymentId": { + "Ref": "deployment333819757133bf57c3fc3772a18d36df02381e2e" + }, + "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..768380d536f4b --- /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, 'handler', { + definition: passTask, + stateMachineType: sfn.StateMachineType.EXPRESS, + }); + + const api = new apigw.StepFunctionsRestApi(this, 'StepFunctions-rest-api', { + deploy: false, + handler: 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..7261e4a1f33e7 --- /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, 'handler', { + 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\": \"$util.escapeJavaScript($input.json('$'))\",\n \"stateMachineArn\": \"", + { + Ref: 'handlerE1533BD5', + }, + '"\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, 'handler', { + 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\": \"$util.escapeJavaScript($input.json('$'))\",\n \"stateMachineArn\": \"", + { + Ref: 'handlerE1533BD5', + }, + '"\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, 'handler', { + 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..b0655eb9e2758 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/stepFunctions-api.test.ts @@ -0,0 +1,239 @@ +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, 'handler', { + definition: passTask, + stateMachineType: sfn.StateMachineType.EXPRESS, + }); + + return { stack, stateMachine }; +} + +function whenCondition(stack:cdk.Stack, stateMachine: sfn.IStateMachine) { + const api = new apigw.StepFunctionsRestApi(stack, 'StepFunctions-rest-api', { handler: 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: 'StepFunctionsrestapi3C602481', + }, + ResourceId: { + 'Fn::GetAtt': [ + 'StepFunctionsrestapi3C602481', + 'RootResourceId', + ], + }, + Integration: { + Credentials: { + 'Fn::GetAtt': [ + 'DefaulthandlerapiRole51A19C22', + 'Arn', + ], + }, + IntegrationHttpMethod: 'POST', + IntegrationResponses: getIntegrationResponse(), + RequestTemplates: { + 'application/json': { + 'Fn::Join': [ + '', + [ + "\n #set($inputRoot = $input.path('$')) {\n \"input\": \"$util.escapeJavaScript($input.json('$'))\",\n \"stateMachineArn\": \"", + { + Ref: 'handlerE1533BD5', + }, + '"\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, 'StepFunctions-rest-api', { + handler: stateMachine, + options: { defaultIntegration: new apigw.HttpIntegration(httpURL) }, + })).toThrow(/Cannot specify \"defaultIntegration\" since Step Functions integration is automatically defined/); + + expect(() => new apigw.StepFunctionsRestApi(stack, 'StepFunctions-rest-api', { + handler: stateMachine, + defaultIntegration: new apigw.HttpIntegration(httpURL), + })).toThrow(/Cannot specify \"defaultIntegration\" since Step Functions integration is automatically defined/); + + }); + + test('StepFunctionsRestApi defines a REST API with CORS enabled', () => { + const { stack, stateMachine } = givenSetup(); + + //WHEN + new apigw.StepFunctionsRestApi(stack, 'stepFunctions-rest-api', { + handler: stateMachine, + defaultCorsPreflightOptions: { + allowOrigins: ['https://aws.amazon.com'], + allowMethods: ['GET', 'PUT'], + }, + }); + + //THEN + expect(stack).toHaveResource('AWS::ApiGateway::Method', { + HttpMethod: 'OPTIONS', + ResourceId: { + 'Fn::GetAtt': [ + 'stepFunctionsrestapi72815E22', + 'RootResourceId', + ], + }, + RestApiId: { + Ref: 'stepFunctionsrestapi72815E22', + }, + 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', + }, + ], + }); + }); +});