Async APIs

Often we find ourselves wanting to offer a REST API, but the processing we do in a request takes longer than the timeouts provided by API Gateway or Lambda or both. In this guide, We'll walk through a common approach to solving this problem by exposing an async API. At a very high level, this architecture allows a client to submit a request, then they immediately receive a token back that can be used to make a subsequent request to query the API for the status of the initial request.

If typing really isn't your thing, you can just download the whole template from [here]

To accomplish this in AWS, we will use API Gateway, Step Functions, and DynamoDB.

So let's just jump right into it. First, we'll create a simple DynamoDB Table that will be used to hold the statuses of submitted requests.

  RequestStatusTable:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: "RequestId"
          AttributeType: "S"
      KeySchema:
        - AttributeName: "RequestId"
          KeyType: "HASH"
      ProvisionedThroughput:
        ReadCapacityUnits: "1"
        WriteCapacityUnits: "1"

Tables are awesome, but if we can't interact with them they are less awesome. Let's go ahead and create some permissions to spread this joy throughout our account. First we start with API Gateway's ability to Query the Table.

  APIGReadDDBRole:
      Type: "AWS::IAM::Role"
      Properties:
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: "Allow"
              Principal:
                Service:
                  - "apigateway.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Path: "/"
        Policies:
          - PolicyName: "APIGQueryDDB"
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: "Allow"
                  Action:
                   - dynamodb:Query
                  Resource: !GetAtt [ RequestStatusTable, Arn ]

Now we'll give Step function the ability to Put Items in the Table

  StatesExecutionRole:
      Type: "AWS::IAM::Role"
      Properties:
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: "Allow"
              Principal:
                Service:
                  - "states.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Path: "/"
        Policies:
          - PolicyName: "putDDBitem"
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: "Allow"
                  Action:
                   - dynamodb:PutItem
                  Resource: !GetAtt [ RequestStatusTable, Arn ]

These two Role are basically identical, but it's easier to start with two different Roles and later decide they should be the same Role then to try to split a large Role into 2 smaller ones as their responsibilities diverge.

We're chugging right along, so let's make a simple Step Function state machine that will do some work and then update

  MyStateMachine:
    Type: "AWS::StepFunctions::StateMachine"
    Properties:
      DefinitionString:
        !Sub
        - |-
          {
            "Comment": "Simple State Machine",
            "StartAt": "Submission",
            "TimeoutSeconds": 300,
            "States": {
             "Submission": {
                  "Type": "Task",
                  "Resource": "arn:aws:states:::dynamodb:putItem",
                  "Parameters": {
                      "TableName": "${DynamoTableName}",
                      "Item": {
                          "RequestId": {"S.$": "$.request-id"},
                          "RequestStatus": {"S": "SUBMITTED"},
                          "UserId": {"S.$": "$.user-id"}
                      }
                  },
                  "ResultPath": "$.Result",
                  "Next": "YourPreProcessingCodeHere"
              },
              "YourPreProcessingCodeHere": {
                    "Type": "Wait",
                    "Seconds": 60,
                    "Next": "StartProcessing"
              },
              "StartProcessing": {
                  "Type": "Task",
                  "Resource": "arn:aws:states:::dynamodb:putItem",
                  "Parameters": {
                      "TableName": "${DynamoTableName}",
                      "Item": {
                          "RequestId": {"S.$": "$.request-id"},
                          "RequestStatus": {"S": "PROCESSING"},
                          "UserId": {"S.$": "$.user-id"}
                      }
                  },
                  "ResultPath": "$.Result",
                  "Next": "YourCodeHere"
              },
              "YourCodeHere": {
                    "Type": "Wait",
                    "Seconds": 60,
                    "Next": "TellDynamoWeDone"
              },
              "TellDynamoWeDone": {
                  "Type": "Task",
                  "Resource": "arn:aws:states:::dynamodb:putItem",
                  "Parameters": {
                      "TableName": "${DynamoTableName}",
                      "Item": {
                          "RequestId": {"S.$": "$.request-id"},
                          "RequestStatus": {"S": "COMPLETE"},
                          "UserId": {"S.$": "$.user-id"}
                      }
                  },
                  "ResultPath": "$.Result",
                  "Next": "Succeed"
              },
              "Succeed": {
                  "Type": "Succeed"
              }
            }
          }
        - {DynamoTableName: !Ref RequestStatusTable}
      RoleArn: !GetAtt [ StatesExecutionRole, Arn ]

Now we get into the fun stuff. There's a few ways to create APIs via CloudFormation, but I prefer defining the relationships as CloudFormation Types (as opposed to one large Swagger definition). Let's start with the API resource itself

  AsyncProcessorAPI:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: "Async Processor API"

Now we'll give it the two base resources.

  StatusReportBaseResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !GetAtt AsyncProcessorAPI.RootResourceId
      PathPart: "status"
      RestApiId: !Ref AsyncProcessorAPI
  CreateUserResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !GetAtt AsyncProcessorAPI.RootResourceId
      PathPart: "user"
      RestApiId: !Ref AsyncProcessorAPI

Choo Choo!!! We're on a late night train to API-ville and we're not slowing down. Let's throw some HTTP Methods on here. First we add the ability to query the status of a record.

  GetStatusMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      HttpMethod: GET
      ResourceId: !Ref StatusReportBaseResource
      RestApiId: !Ref AsyncProcessorAPI
      AuthorizationType: AWS_IAM
      RequestParameters:
        method.request.querystring.request-id: true
      MethodResponses:
        - ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true
          StatusCode: 200
      Integration:
        Type: AWS
        IntegrationHttpMethod: POST
        RequestParameters:
          integration.request.querystring.request-id: 'method.request.querystring.request-id'
        Credentials:
          Fn::GetAtt:
            - APIGReadDDBRole
            - Arn
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:dynamodb:action/Query"
        RequestTemplates:
          application/json:
            Fn::Sub:
              - |
                #if($input.params('request-id'))
                {
                  "TableName": "${tableName}",
                  "KeyConditionExpression": "PartitionKey = :v1",
                  "ExpressionAttributeValues": {":v1": {"S": "$input.params('request-id')"}}
                }
                #end
              - tableName: !Ref RequestStatusTable
        IntegrationResponses:
          - StatusCode: 200
            ResponseTemplates:
              application/json:
                Fn::Sub: |
                  #set($inputRoot = $input.path('$'))
                  {[
                  #foreach($elem in $inputRoot.Items)
                    {"status": $elem.SortKey.S}#if($foreach.hasNext),#end

                  #end
                  ]}
          - StatusCode: 500

Awesome. Now that we can get data out of the system, let's add something that lets us put some data into it. We'll add the API Gateway to Step Functions integration

  SubmitNewUserMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      HttpMethod: POST
      ResourceId: !Ref CreateUserResource
      RestApiId: !Ref AsyncProcessorAPI
      AuthorizationType: AWS_IAM
      MethodResponses:
        - ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true
          StatusCode: 200
      Integration:
        Type: AWS
        IntegrationHttpMethod: POST
        Credentials:
          Fn::GetAtt:
            - ExecuteStateMachineRole
            - Arn
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:states:action/StartExecution"
        RequestTemplates:
          application/json:
            Fn::Sub:
              - |
                {
                  "input": "{\"user-id\": \"$input.params('user-id')\", \"request-id\": \"$context.requestId\", \"name\": \"$context.requestId\"}",
                  "name": "$context.requestId",
                  "stateMachineArn": "${stepFunctionArn}"
                }
              - stepFunctionArn: !Ref MyStateMachine
        IntegrationResponses:
          - StatusCode: 200
            ResponseTemplates:
              application/json:
                Fn::Sub: |
                  {$context.requestId}
          - StatusCode: 500

Whoa, we almost missed something. Can you see it? "ExecuteStateMachineRole" hasn't been defined yet. This Role gives API Gateway the ability to invoke Step Function state machines.. So let's go ahead and add that now.

  ExecuteStateMachineRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "apigateway.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: "/"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSStepFunctionsFullAccess

After deploying it, let's run through an example. First we submit a new user named "Richard"

Let's see what happens? We get a request-id back (yes, I should clean up that response object, but then it wouldn't leave anything for you to do when you deploy this template). Now let's see what happens when we check the status of our request.

Let's wait about 60 seconds and check again

That looks promising, let's give it another minute to work through the "wait task" we added for the "YourCodeHere" Step.

Now we're done.