API Gateway REST API: Step Functions direct integration – AWS CDK guide
Tags: aws, cdk, serverless
Published: 2021-11-06 04:42:07

---
title: API Gateway REST API: Step Functions direct integration – AWS CDK guide
published: true
description: Harness the power of AWS Step Functions and AWS Amazon Gateway to build robust APIs.
tags: aws, cdk, serverless
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7zl1wsoobrkzof50wmah.jpeg
---
The recent addition to the [_AWS Step Functions_](https://aws.amazon.com/step-functions/) service sparked many conversations in the AWS serverless community. This is very much understandable as having the option to integrate _AWS Step Functions_ with almost every AWS service directly is like having superpowers.

This blog post will walk you through creating direct integration between _AWS Step Functions_ and [_Amazon API Gateway_](https://aws.amazon.com/api-gateway/) (REST APIs). By utilizing _Step Functions_ and _API Gateway_ [VTL transformations](https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-data-transformations.html), the architecture will allow you to create the whole APIs without deploying any AWS Lambda functions at all!

Let us dive in.

> All the code examples will be written in TypeScript. You can find the [GitHub repository with code from this blog here](https://github.com/WojciechMatuszewski/apigw-rest-sfn-direct-integration-example).

## The API

Creating the _API Gateway REST API_ with _AWS CDK_ is pretty much painless.
**The first step** is to create the _RestApi_ resource.

```ts
import * as apigw from "@aws-cdk/aws-apigateway";

// Stack definition and the constructor ...

const API = new apigw.RestApi(this, "API", {
  defaultCorsPreflightOptions: {
    /**
     * The allow rules are a bit relaxed.
     * I would strongly advise you to narrow them down in your applications.
     */
    allowOrigins: apigw.Cors.ALL_ORIGINS,
    allowMethods: apigw.Cors.ALL_METHODS,
    allowHeaders: ["*"],
    allowCredentials: true
  }
});
```

Since our example will be using the _POST_ HTTP method, I've opted into specifying the `defaultCorsPreflightOptions`. Please note that **this property alone does not mean are done with CORS**. The `defaultCorsPreflightOptions` and the `addCorsPreflight` on the method level create an _OPTIONS_ method alongside the method you initially created. This means that the _API Gateway_ service will handle _OPTIONS_ part of the _CORS_ flow for you, but you **will still need to return correct headers from within your integration**. We will address this part later on.

The **second step** is to create a _resource_ which is nothing more than an API route.
Let us create a route with a path of `/create`.

```ts
const API = // the API definition from earlier.

const createPetResource = API.root.addResource("create");
```

We have not yet integrated our API path with any HTTP verb nor AWS service. We will do this later on after defining the orchestrator powering our API - the _AWS Step Functions_ state machine.

## The Step Functions state machine

Since this blog post is not a tutorial on _Step Functions_ our _Step Functions state machine_ will be minimalistic.
Here is how one might define such a state machine using _AWS CDK_.

```ts
import * as sfn from "@aws-cdk/aws-stepfunctions";
import * as logs from "@aws-cdk/aws-logs";

// Previously written code and imports...

const APIOrchestratorMachine = new sfn.StateMachine(
  this,
  "APIOrchestratorMachine",
  {
    stateMachineType: sfn.StateMachineType.EXPRESS,
    definition: new sfn.Pass(this, "PassTask"),
    logs: {
      level: sfn.LogLevel.ALL,
      destination: new logs.LogGroup(this, "SFNLogGroup", {
        retention: logs.RetentionDays.ONE_DAY
      }),
      includeExecutionData: true
    }
  }
);
```

Since we are building synchronous API, I've defined the type of the state machine as _EXPRESS_. If you are not sure what the difference is between the _EXPRESS_ and _STANDARD_ (default) types, please refer to [this AWS documentation page](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-standard-vs-express.html).

In a real-world scenario, I would not use the _EXPRESS_ version of the state machine from the get-go. The _EXPRESS_ type is excellent for cost and performance, but I find the "regular" state machine type better for development purposes due to rich workflow visualization features.

As I eluded earlier, the definition of the state machine is minimalistic. The `PassTask` will return everything from machine input as the output.
I encourage you to give it a try and extend the definition to include calls to different AWS services. Remember that you most likely [do not need an _AWS Lambda_ function to do that](https://docs.aws.amazon.com/step-functions/latest/dg/supported-services-awssdk.html).

## The Integration

Defining direct integration between _Amazon API Gateway_ and _AWS Step Functions_ will take up most of our code. The _Amazon API Gateway_ is a feature-rich service. It exposes a lot of knobs one might adjust to their needs. We will distill all the settings to only the ones relevant to our use case.

```ts
const createPetResource = API.root.addResource("create");

createPetResource.addMethod(
  "POST",
  new apigw.Integration({
    type: apigw.IntegrationType.AWS,
    integrationHttpMethod: "POST",
    uri: `arn:aws:apigateway:${cdk.Aws.REGION}:states:action/StartSyncExecution`,
    options: {}
  }),
  // Method options \/. We will take care of them later.
  {}
);
```

The most **important part of this snippet is the `uri` property**. This property tells the _API Gateway_ what AWS service to invoke whenever the route is invoked. The documentation around `uri` is, in my opinion, not easily discoverable. I found the [_Amazon API Gateway_ API reference page](https://docs.aws.amazon.com/apigateway/api-reference/resource/integration/) helpful with learning what the `uri` is about.

With the skeleton out of the way, we are ready to dive into the `options` parameter.

### The integration options

Our integration tells the _Amazon API Gateway_ service to invoke a _Step Functions_ state machine, but we never specified which one!

This is where the `requestTemplates` property comes in handy. To target the `APIOrchestratorMachine` resource that will power our API, the _ARN_ of that state machine has to be forwarded to the _Step Functions_ service.

```ts
const APIOrchestratorMachine = // state machine defined earlier...

const createPetResource = API.root.addResource("create");

createPetResource.addMethod(
  "POST",
  new apigw.Integration({
    // other properties ...
    options: {
      passthroughBehavior: apigw.PassthroughBehavior.NEVER,
      requestTemplates: {
        "application/json": `{
          "input": "{\\"actionType\\": \\"create\\", \\"body\\": $util.escapeJavaScript($input.json('$'))}",
          "stateMachineArn": "${APIOrchestratorMachine.stateMachineArn}"
        }`
      }
    }
  })
);
```

The **`requestTemplates`** is a `key: value` structure that specifies a given mapping template for an input data encoding scheme – I assumed that every request made to the endpoint would be encoded as `application/json`.

The data I'm forming in the request template must obey the [`StartSyncExecution` API call](https://docs.aws.amazon.com/step-functions/latest/apireference/API_StartSyncExecution.html) validation rules.

---

Let us tackle **[_AWS IAM_](https://aws.amazon.com/iam/)** next.

Our definition tells the _Amazon API Gateway_ which state machine to invoke (via the `requestTemplates` property). It is not specifying the role _API Gateway_ could assume so that it has permissions to invoke the state machine.

Enter the `credentialsRole` parameter. The _Amazon API Gateway_ service will use this role to invoke the state machine when specified.
Luckily for us, creating such a role using _AWS CDK_ is not much of a hassle.

```ts
import * as iam from "@aws-cdk/aws-iam";

const APIOrchestratorMachine = // state machine definition

const invokeSFNAPIRole = new iam.Role(this, "invokeSFNAPIRole", {
  assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
  inlinePolicies: {
    allowSFNInvoke: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["states:StartSyncExecution"],
          resources: [APIOrchestratorMachine.stateMachineArn]
        })
      ]
    })
  }
});

const createPetResource = API.root.addResource("create");
createPetResource.addMethod(
  "POST",
  new apigw.Integration({
    // Properties we have specified so far...
    options: {
      credentialsRole: invokeSFNAPIRole
    }
  }),
  // Method options \/. We will take care of them later.
  {}
);
```

The role is allowed to be assumed by _Amazon API Gateway_ (the `assumedBy` property) service and provides permissions for invoking the state machine we have created (the `states:StartSyncExecution` statement).

---

Since our `/create` route accepts POST requests, the response must contain appropriate CORS headers. Otherwise, users of our API might not be able to use our API.

Just like we have modified the incoming request to fit the `StartSyncExecution` API call validation rules (via the `requestTemplates` parameter), we can validate the response from the _AWS Step Functions_ service.

This is done by specifying the `integrationResponses` parameter. The `integrationResponses` is an array of response transformations. Each transformation corresponds to the status code **returned by the integration, not the _Amazon API Gateway_ service**.

```ts
const createPetResource = API.root.addResource("create");
createPetResource.addMethod(
  "POST",
  new apigw.Integration({
    // Properties we have specified so far...
    options: {
      integrationResponses: [
        {
          selectionPattern: "200",
          statusCode: "201",
          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
                {
                  "id": "$context.requestId",
                  "output": "$util.escapeJavaScript($input.path('$.output'))"
                }
              #end
            `
          },
          responseParameters: {
            "method.response.header.Access-Control-Allow-Methods":
              "'OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD'",
            "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": "'*'"
          }
        }
      ]
    }
  }),
  // Method options \/. We will take care of them later.
  {}
);
```

The above `integrationResponse` specifies that if the _AWS Step Functions_ service returns with a `statusCode` of 200 (controlled by the `selectionPattern` and **not** the `statusCode`), the transformations within the `responseTemplates` and `responseParameters` will be applied to the response and the _Amazon API Gateway_ will return with the `statusCode` of 201 to the caller.

Take note of the `responseParameters` section where we specify CORS-related response headers. This is only an example. In a real-world scenario, I would not recommend putting `*` as the value for `Access-Control-Allow-Origin` header.

The [_mapping template_](https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html) is a bit involved.
The following block of code

```txt
#if($input.path('$.status').toString().equals("FAILED"))
```

is responsible for checking if a given execution failed. **This condition has nothing to do with checking if _AWS Step Functions_ service failed**.
The conditions checks whether given state machine execution failed or not.

For _AWS Step Functions_ service failure, we need to create another mapping template to handle such a scenario. This is out of the scope of this blog post.

### The method options

The method options ([_AWS CloudFormation reference_](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-method.html)) contain properties that allow you to specify request/response validation, **which `responseParameters` are allowed for which `statusCode`** (very relevant for us), and various other settings regarding _Amazon API Gateway_ route method.

In the previous section, we have specified the `responseParameters` so that the response surfaced by _Amazon API Gateway_ contains CORS-related headers.
To **make the `responseParameters` fully functional, one needs to specify correct `methodResponse`**.

```ts
createPetResource.addMethod(
  "POST",
  new apigw.Integration({
    // Properties we have specified so far...
    options: {
      integrationResponses: [
        {
          // Other `integrationResponse` parameters we have specified ...
          responseParameters: {
            "method.response.header.Access-Control-Allow-Methods":
              "'OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD'",
            "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": "'*'"
          }
        }
      ]
    }
  }),
  {
    methodResponses: [
      {
        statusCode: "201",
        // Allows the following `responseParameters` be specified in the `integrationResponses` section.
        responseParameters: {
          "method.response.header.Access-Control-Allow-Methods": true,
          "method.response.header.Access-Control-Allow-Headers": true,
          "method.response.header.Access-Control-Allow-Origin": true
        }
      }
    ]
  }
);
```

In my opinion, the resource definition would be less confusing if the `methodResponses` (and the whole method options section) would come before the `integration`. I find it a bit awkward that we first have to specify the `responseParameters` in the request and THEN specify which ones can be returned in the `methodResponse` section.

## Bringing it all together

Phew! That was a relatively large amount of code to write. Luckily, engineers contributing to the _AWS CDK_ are already preparing an abstraction for us to make this process much more manageable. [Follow this PR](https://github.com/aws/aws-cdk/pull/16827) to see the progress made.

Here is all the code that we have written together. The code is also available on [my GitHub](https://github.com/WojciechMatuszewski/apigw-rest-sfn-direct-integration-example).

```ts
const API = new apigw.RestApi(this, "API", {
  defaultCorsPreflightOptions: {
    /**
     * The allow rules are a bit relaxed.
     * I would strongly advise you to narrow them down in your applications.
     */
    allowOrigins: apigw.Cors.ALL_ORIGINS,
    allowMethods: apigw.Cors.ALL_METHODS,
    allowHeaders: ["*"],
    allowCredentials: true
  }
});

new cdk.CfnOutput(this, "APIEndpoint", {
  value: API.urlForPath("/create")
});

const APIOrchestratorMachine = new sfn.StateMachine(
  this,
  "APIOrchestratorMachine",
  {
    stateMachineType: sfn.StateMachineType.EXPRESS,
    definition: new sfn.Pass(this, "PassTask"),
    logs: {
      level: sfn.LogLevel.ALL,
      destination: new logs.LogGroup(this, "SFNLogGroup", {
        retention: logs.RetentionDays.ONE_DAY
      }),
      includeExecutionData: true
    }
  }
);

const invokeSFNAPIRole = new iam.Role(this, "invokeSFNAPIRole", {
  assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
  inlinePolicies: {
    allowSFNInvoke: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["states:StartSyncExecution"],
          resources: [APIOrchestratorMachine.stateMachineArn]
        })
      ]
    })
  }
});

const createPetResource = API.root.addResource("create");
createPetResource.addMethod(
  "POST",
  new apigw.Integration({
    type: apigw.IntegrationType.AWS,
    integrationHttpMethod: "POST",
    uri: `arn:aws:apigateway:${cdk.Aws.REGION}:states:action/StartSyncExecution`,
    options: {
      credentialsRole: invokeSFNAPIRole,
      passthroughBehavior: apigw.PassthroughBehavior.NEVER,
      requestTemplates: {
        "application/json": `{
              "input": "{\\"actionType\\": \\"create\\", \\"body\\": $util.escapeJavaScript($input.json('$'))}",
              "stateMachineArn": "${APIOrchestratorMachine.stateMachineArn}"
            }`
      },
      integrationResponses: [
        {
          selectionPattern: "200",
          statusCode: "201",
          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
                    {
                      "id": "$context.requestId",
                      "output": "$util.escapeJavaScript($input.path('$.output'))"
                    }
                  #end
                `
          },
          responseParameters: {
            "method.response.header.Access-Control-Allow-Methods":
              "'OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD'",
            "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": "'*'"
          }
        }
      ]
    }
  }),
  {
    methodResponses: [
      {
        statusCode: "201",
        responseParameters: {
          "method.response.header.Access-Control-Allow-Methods": true,
          "method.response.header.Access-Control-Allow-Headers": true,
          "method.response.header.Access-Control-Allow-Origin": true
        }
      }
    ]
  }
);
```

## Next steps

To make sure this blog post is not overly long, I've omitted some of the code I would usually write in addition to what we already have.
Here are some ideas what you might want to include in the integration definition:

- Add request and response validation via [API models](https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-create-model.html).
- Amend the state machine definition to leverage the [SDK service integrations](https://docs.aws.amazon.com/step-functions/latest/dg/supported-services-awssdk.html).
- Add more entries in the `integrationResponses`/`methodResponses` errors from the _AWS Step Functions_ service itself.

## Closing words

Integrating various AWS services directly is a great way to save yourself from one of the most significant liabilities in serverless systems – AWS Lambdas code. You might be skeptical at first, as writing those services together is usually not the easiest thing to do, but believe me, the upfront effort is worth it.

I hope that you found this blog post helpful.

Thank you for your time.

---

For questions, comments, and more serverless content, check out my Twitter – [@wm_matuszewski](https://twitter.com/wm_matuszewski)