AWS Developer Blog

Handling arbitrary HTTP requests in Amazon API Gateway

In this post, I walk you through the steps to build a serverless web application that can accept arbitrary HTTP requests and use custom logic to return arbitrary responses. The concepts in this post are applicable to any situation where you require flexible control over the HTTP requests received and returned by an application that uses Amazon API Gateway.

You can find all of the code and resources used throughout this post in the associated GitHub repository.

Solution background

You are building a web application for a technical, puzzle-solving competition. Some parts of the competition require entrants to construct and send specific types of HTTP requests to the application. Your application also must be able to construct and return custom responses.

You know in advance that traffic to the application will be sporadic and unpredictable as people work their way through the competition. So, you opt to build a serverless application using AWS CloudFormation, AWS Lambda, and Amazon API Gateway.

The web application has the following requirements:

  1. The application code must be able to accept and handle HTTP requests with these requirements:
    1. At any path
    2. Using any method
    3. Have access to the request’s headers
    4. Have access to the request’s body
  2. The HTTP responses sent by the application must also be similarly configurable, including sending custom headers and response body.
  3. The application must define all of its infrastructure as code, so that you can easily deploy and test multiple environments as necessary.

Groundwork

Most of the requirements are met by using the ANY method and proxy path features of API Gateway, as outlined in API Gateway Update – New Features Simplify API Development.

If you haven’t read that post, here’s a short summary of the outcome: An API Gateway API with a single resource that has the path /{proxy+} and the method ANY. The resource is configured to proxy all requests to a Lambda function using the Lambda proxy integration feature of API Gateway.

Screenshot of API Gateway console with LAMBDA_PROXY configuration

For now, the Lambda function simply returns the integration request input as JSON. The Python code for the Lambda function looks like the following code example:

import json

def handler(event, _):
    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/json",
        },
        "body": json.dumps(event, indent=4),
    }

This code example instructs API Gateway to send back a 200 response, with the Content-Type header set to application/json. It also sets the response body to a JSON-encoded copy of the event object received by the Lambda function.

Because you intend to define the entire application as code, re-create the preceding API Gateway configuration as an OpenAPI document (which you later embed into an AWS CloudFormation template):

{
    "openapi": "3.0",
    "info": {
        "title": "All-capturing example",
        "version": "1.0"
    },
    "paths": {
        "/{proxy+}": {
            "x-amazon-apigateway-any-method": {
                "responses": {},
                "x-amazon-apigateway-integration": {
                    "httpMethod": "POST",
                    "type": "aws_proxy",
                    "uri": ""
                }
            }
        }
    }
}

You can now make a request to the API endpoint and get the integration request data back:

curl -i http://abcdef.execute-api.us-east-1.amazonaws.com/Prod/foo

content-type: application/json
content-length: 3332
date: Wed, 21 Aug 2019 15:43:03 GMT

{
   // Some fields removed for brevity
   "path": "/foo",
    "httpMethod": "GET",
    "headers": {
    },
    "queryStringParameters": null,
    "body": null,
    "isBase64Encoded": false
}

Walkthrough

You now have the basis of the application, but there are still a few things left to configure to meet all of the requirements.

Handling all paths

The application must be able to receive requests at any path, including the root path: /. An API Gateway resource with a path of /{proxy+} captures every path except the root path. Making a request for the root path results in a 403 response from API Gateway with the message Missing Authentication Token.

To fix this omission, add an additional resource to the API with the path set to / and link that new resource to the same Lambda function as used in the existing /{proxy+} resource.

Screenshot of API Gateway console showing Proxy path with ANY method

The updated OpenAPI document now looks like the following code example:

{
    "openapi": "3.0",
    "info": {
        "title": "All-capturing example",
        "version": "1.0"
    },
    "paths": {
        "/": {
            "x-amazon-apigateway-any-method": {
                "responses": {},
                "x-amazon-apigateway-integration": {
                    "httpMethod": "POST",
                    "type": "aws_proxy",
                    "uri": ""
                }
            }
        },
        "/{proxy+}": {
            "x-amazon-apigateway-any-method": {
                "responses": {},
                "x-amazon-apigateway-integration": {
                    "httpMethod": "POST",
                    "type": "aws_proxy",
                    "uri": ""
                }
            }
        }
    }
}

Handling all content types

Now your application can handle any type of HTTP request at any path, but there’s one significant issue remaining: handling binary data.

If you POST a binary payload to the API at the moment, the response can be corrupted by automatic encoding and decoding to and from textual representations of the binary payload. To demonstrate this, use a 1×1 pixel GIF file that is 35 bytes long. If you post it to the API, you get the following response back (again, simply echoing the request that you sent):

curl --data-binary "@1pixel.gif" http://abcdef.execute-api.us-east-1.amazonaws.com/Prod/

{
    // Some fields removed for brevity
   "path": "/",
    "httpMethod": "POST",
    "headers": {
        "content-type": "image/gif",
    },
    "queryStringParameters": null,
    "body": "GIF87a\u0001\u0000\u0001\u0000\ufffd\u0002\u0000\u0000\u0000\u0000\ufffd\ufffd\ufffd,\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0000\u0000\u0002\u0002L\u0001\u0000;",
    "isBase64Encoded": false
}

As you can see from the body element in the preceding JSON response, the Lambda function has received a string containing several UTF-8 characters. There is no clear way to correctly decode that payload back into the original binary image data. You may have noticed the flag isBase64Encoded that is included in the request data that gets echoed back. That flag holds the key to solving the problem of receiving binary payloads.

When creating an API Gateway API, you can specify that certain content types should be treated as binary. When you do this, you’re telling API Gateway to encode that binary data using base64 encoding, which can safely be decoded by the receiving function. You can find this under Settings in the API Gateway console.

Screenshot of API Gateway console showing a Binary Media Type of image/gif

If you add image/gif as a binary media type to the API and retry the preceding command, you get the following response:

curl --data-binary "@1pixel.gif" http://abcdef.execute-api.us-east-1.amazonaws.com/Prod/

{
    // Some fields removed for brevity
   "path": "/",
    "httpMethod": "POST",
    "headers": {
        "content-type": "image/gif",
    },
    "queryStringParameters": null,
    "body": "R0lGODdhAQABAIACAAAAAP///ywAAAAAAQABAAACAkwBADs=",
    "isBase64Encoded": true
}

You see in the preceding code that the isBase64Encoded flag is set to true and that the body looks different. In fact, the body is now a correct base64-encoding of the 1×1 pixel GIF image.

You could continue to add MIME types to the API to cover all of the options required. However, because you want the application code to be able to handle arbitrary content types, you can instead use a wildcard content type: */*.

Returning binary responses

Now that you have dealt with all of the requirements regarding incoming HTTP requests, check that you can meet the requirements for sending custom responses.

From the API Gateway documentation, all of your requirements are met by using the Lambda proxy integration’s output model:

{
    "isBase64Encoded": true|false,
    "statusCode": httpStatusCode,
    "headers": { "headerName": "headerValue", ... },
    "multiValueHeaders": { "headerName": ["headerValue", "headerValue2", ...], ... },
    "body": "..."
}

You can send any HTTP status code by setting the statusCode field, any headers required by setting either headers or multiValueHeaders, and any response payload by populating the body field. If the response payload contains binary data, you can base64-encode the data and set the isBase64Encoded field to true.

To demonstrate these features, modify the Lambda function so that the response returned by the API echoes the original HTTP request that was sent to it, rather than echoing the integration request as before. Here is the Python code:

import json

def handler(event, _):
    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": event["headers"]["content-type"],
        },
        "body": event["body"],
        "isBase64Encoded": event["isBase64Encoded"],
    }

For example, if you send a 1×1 pixel GIF to the API now, you receive a response containing an exact copy of the same image with the same Content-Type header.

Putting it all together

Now that you have everything working as intended, the final stage is to wrap everything in AWS CloudFormation so that you have repeatable deployments. Use the AWS Serverless Application Model (AWS SAM) because it contains useful resource types. That means that the AWS CloudFormation template can be more expressive and readable.

First, define the Lambda function:

Function:
  Type: "AWS::Serverless::Function"
  Properties:
    CodeUri: echo.py
    Handler: echo.handler
    Runtime: python3.6
    Events:
      Base:
        Type: Api
        Properties:
          Method: any
          Path: /
          RestApiId: !Ref Api
      Others:
        Type: Api
        Properties:
          Method: any
          Path: /{proxy+}
          RestApiId: !Ref Api

The Events section defines URL paths that map to the API definition that you add next:

Api:
  Type: "AWS::Serverless::Api"
  Properties:
    StageName: Prod
    BinaryMediaTypes:
      - "~1"
    DefinitionBody:
      openapi: "3.0"
      info:
        title: !Ref "AWS::StackName"
        version: "1.0"
      paths:
        /:
          x-amazon-apigateway-any-method:
            responses:
              {}
            x-amazon-apigateway-integration:
              httpMethod: POST
              type: aws_proxy
              uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Function.Arn}/invocations"
        /{proxy+}:
          x-amazon-apigateway-any-method:
            responses:
              {}
            x-amazon-apigateway-integration:
              httpMethod: POST
              type: aws_proxy
              uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Function.Arn}/invocations"

There are a couple of things to note here. First, the BinaryMediaTypes property contains *~1*, which is an encoding required by AWS SAM that translates into the wildcard */* string. Secondly, you replaced the uri properties within the embedded OpenAPI document to use the !Sub function. This is so that you can avoid hardcoding the Lambda function ARN.

Last of all, add an Outputs section to the CloudFormation template so that you can easily access the URL of the API Gateway endpoint.

Outputs:
  Endpoint:
    Value: !Sub "https://${Api}.execute-api.${AWS::Region}.amazonaws.com/Prod/"

When you deploy this template, AWS SAM translates the two resources (the Lambda function and API) into all of the AWS resources required:

Screenshot of CloudFormation console showing all resources deployed by the SAM template

You can see the full AWS CloudFormation template along with the code for the Lambda function and instructions for deploying it to your account in amazon-api-gateway-custom-request-response GitHub repo.

Summary

To recap, here is a list of the configurations required to create an API Gateway API that can send and receive arbitrary HTTP requests and responses through a Lambda function:

  1. Use the ANY method to capture all request types (such as GETPOST, and HEAD).
  2. Create two paths:
    1. One for the root URL: /
    2. One for every other URL: /{proxy+}
  3. Set the binary media types setting to */*.
  4. In your Lambda function, take the following steps:
    1. If isBase64Encoded is true, base64-decode the body element of the request.
    2. To specify the HTTP response code, set the statusCode element of the response object.
    3. Add any custom headers to the headers object of the response object.
    4. To send a binary response, base64-encoded the body element of the response and set isBase64Encoded to true.