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.