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 21, 2021
1 parent 00a8063 commit 15c0f85
Show file tree
Hide file tree
Showing 10 changed files with 1,206 additions and 2 deletions.
24 changes: 24 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 Synchronous State Machine backed APIs](#aws-synchronous-state-machine-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,29 @@ item.addMethod('GET'); // GET /items/{item}
item.addMethod('DELETE', new apigateway.HttpIntegration('http://amazon.com'));
```

## AWS Synchronous State Machine backed APIs

You can use Amazon API Gateway with AWS Synchronous Express State Machine as the backend integration. 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 Synchronous Express State Machine:

```ts
declare const stateMachine: stepFunctions.StateMachine;
new apigateway.StepFunctionsRestApi(this, 'StepFunctions-rest-api', {
handler: stateMachine,
});
```

The following code defines a REST API like above but also adds requestContext (similar to input requestContext from lambda input) to the input of the State Machine:

```ts
declare const stateMachine: stepFunctions.StateMachine;
new apigateway.StepFunctionsRestApi(this, 'StepFunctions-rest-api', {
handler: 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';
177 changes: 177 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,177 @@
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;

/**
* 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 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, options.includeRequestContext);

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, includeRequestContext: boolean | undefined) {
const templateString = getTemplateString(handler, includeRequestContext);

const requestTemplate: { [contentType:string] : string } =
{
'application/json': templateString,
};

return requestTemplate;
}

function getTemplateString(handler: 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": "${handler.stateMachineArn}"
}`;
} else {
templateString = `
#set($inputRoot = $input.path('$')) {
"input": "{\\"body\\": $util.escapeJavaScript($input.json('$'))}",
"stateMachineArn": "${handler.stateMachineArn}"
}`;
}
return templateString;
}
128 changes: 128 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,128 @@
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;

/**
* 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.handler.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.handler, {
credentialsRole: apiRole,
proxy: false, //proxy not avaialble for Step Functions yet
corsEnabled: corsEnabled,
includeRequestContext: props.includeRequestContext,
}),
...props,
});

this.root.addMethod('ANY', new StepFunctionsIntegration(props.handler, {
credentialsRole: apiRole,
includeRequestContext: props.includeRequestContext,
}), {
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;
}
7 changes: 5 additions & 2 deletions packages/@aws-cdk/aws-apigateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
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 15c0f85

Please sign in to comment.