Skip to content

Commit

Permalink
StepFunctionsRestApi implemented along with unit and integration test…
Browse files Browse the repository at this point in the history
…ing.

Fixed Integration test and generated expected json for stepFunctionsRestApi Stack deployment.
Added code snippet to the README.
Removing restApiprops option as the composition in StepFunctionsRestApiProps.
Added Error for when state machine is not of type EXPRESS
Added Context to input with includeRequestContext boolean varibale to pass requestContext to State Machine input.
closes aws#15081.
  • Loading branch information
Saqib Dhuka committed Oct 26, 2021
1 parent 924045d commit 81f9055
Show file tree
Hide file tree
Showing 10 changed files with 1,199 additions and 0 deletions.
26 changes: 26 additions & 0 deletions packages/@aws-cdk/aws-apigateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-apigateway/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-apigateway/lib/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './aws';
export * from './lambda';
export * from './http';
export * from './mock';
export * from './stepfunctions';
166 changes: 166 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/integrations/stepfunctions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
132 changes: 132 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/stepfunctions-api.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/@aws-cdk/aws-apigateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 81f9055

Please sign in to comment.