adrianhesketh.com

AWS API Gateway V2 to Go Lambda HTTP Handler Adaptor

Why use API Gateway V2 instead of V1?

API Gateway V2 is faster and cheaper than V1, but packs fewer features, as per the comparison table [0].

AWS refers to API Gateway V2 as the “HTTP API” in documentation, and V2 in the API calls, whereas V1 is called the “REST API” in the documentation.

I don’t use any of the missing features in my work except for X-Ray, but it’s possible to start X-Ray from within AWS Lambda instead of from API Gateway to get most of the benefits.

For $2 less per 1M API requests and reduced latency, it’s worth the slight loss of X-Ray information for me.

Lambda handlers

A Go Lambda handler that’s designed to handle API Gateway V2 requests will need to accept the events.APIGatewayV2HTTPRequest type.

events.APIGatewayV2HTTPRequest is slightly different to the previous version. Differences include that the path is now rawPath and httpMethod has been moved into the requestContext.

These changes are enough to break things, so existing code would need to be updated to support the V2 structure.

Go handlers

Go’s HTTP package has a standard HTTP handler interface, called http.Handler.

Anything that wants to receive and respond to a HTTP request needs to implement the interface by adding a ServeHTTP(w *http.ResponseWriter, r *http.Request) method to a type.

type Handler struct {
}

func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	io.WriteString(w, "Hello")
}

Why use an adaptor instead of receiving Lambda payloads directly?

Makes it possible to use the Go library ecosystem

There’s a huge ecosystem of Go code that uses the http.Handler interface, including static file serving and authentication middleware. Using an adaptor allows all of these tools to be used in Lambda without modification.

Makes it possible to run in Lambda, standalone or in a container

If you use Go’s standard HTTP library, you can get your code to run as a web server that you can run locally or deploy as a container and run in Lambda. This makes testing your code locally much easier.

if runLocally {
	http.ListenAndServe("localhost:8000", http.DefaultServeMux)
} else {
	awsapigatewayv2handler.ListenAndServe(http.DefaultServeMux)
}

What other adaptors are available?

For years, I’ve been using github.com/akrylysov/algnhsa [2], but it doesn’t yet have support for API Gateway V2.

I raised a PR to fix it, but it hasn’t been merged yet [3].

AWS has their own adaptor [4], but that’s pending V2 support too. There’s an issue outstanding for over a year [5], and some PRs that haven’t been merged. I’m sure they’ll get around to it, but I wanted something in the meantime.

AWS’s adaptor isn’t as easy to use as https://github.com/akrylysov/algnhsa. There’s no obvious documentation of how to use it with the standard library, but I figured it out and raised a PR [6].

package main

import (
	"io"
	"net/http"

	"github.com/aws/aws-lambda-go/lambda"
	"github.com/awslabs/aws-lambda-go-api-proxy/httpadapter"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		io.WriteString(w, "Hello")
	})

	lambda.Start(httpadapter.New(http.DefaultServeMux).ProxyWithContext)
}

So what’s the plan?

Sigh… I wrote my own for use until there’s a standard pattern that works [7].

My adaptor is only 145 lines of code has only two dependencies, github.com/aws/aws-lambda-go and github.com/google/go-cmp for use in the tests.

I’ve tried to make it easy to use.

package main

import (
	"io"
	"net/http"

	"github.com/a-h/awsapigatewayv2handler"
)

func main() {
	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		io.WriteString(w, "Hello")
	})
	awsapigatewayv2handler.ListenAndServe(h)
}

What’s the performance cost of the adaptor?

From CPU profiling, most of the CPU cost is spent doing JSON serialization / deserialization, and base64 encoding / decoding to support binary responses, which would have been done anyway. So the cost is just the creation of a couple of extra buffers to store the request / response.

What about X-Ray?

I’ve put together an example of how X-Ray can be used in the example directory [8], and how to embed a file system into the HTTP server.

CDK?

The example also includes a CDK project I’ve used to test everything.

package main

import (
	"github.com/aws/aws-cdk-go/awscdk/v2"
	"github.com/aws/aws-cdk-go/awscdk/v2/awslambda"
	awsapigatewayv2 "github.com/aws/aws-cdk-go/awscdkapigatewayv2alpha/v2"
	awsapigatewayv2integrations "github.com/aws/aws-cdk-go/awscdkapigatewayv2integrationsalpha/v2"
	awslambdago "github.com/aws/aws-cdk-go/awscdklambdagoalpha/v2"
	"github.com/aws/constructs-go/constructs/v10"
	jsii "github.com/aws/jsii-runtime-go"
)

func NewExampleStack(scope constructs.Construct, id string, props *awscdk.StackProps) awscdk.Stack {
	stack := awscdk.NewStack(scope, &id, props)

	bundlingOptions := &awslambdago.BundlingOptions{
		GoBuildFlags: &[]*string{jsii.String(`-ldflags "-s -w"`)},
	}
	f := awslambdago.NewGoFunction(stack, jsii.String("handler"), &awslambdago.GoFunctionProps{
		Runtime:    awslambda.Runtime_GO_1_X(),
		Entry:      jsii.String("../lambda"),
		Bundling:   bundlingOptions,
		MemorySize: jsii.Number(1024),
		Timeout:    awscdk.Duration_Millis(jsii.Number(15000)),
		Tracing:    awslambda.Tracing_ACTIVE,
		Environment: &map[string]*string{
			"AWS_XRAY_CONTEXT_MISSING": jsii.String("IGNORE_ERROR"),
		},
	})
	fi := awsapigatewayv2integrations.NewHttpLambdaIntegration(jsii.String("handlerIntegration"), f, &awsapigatewayv2integrations.HttpLambdaIntegrationProps{})
	endpoint := awsapigatewayv2.NewHttpApi(stack, jsii.String("apigatewayV2Example"), &awsapigatewayv2.HttpApiProps{
		DefaultIntegration: fi,
	})
	awscdk.NewCfnOutput(stack, jsii.String("apigatewayV2ExampleUrl"), &awscdk.CfnOutputProps{
		ExportName: jsii.String("apigatewayV2ExampleUrl"),
		Value:      endpoint.Url(),
	})

	return stack
}

func main() {
	app := awscdk.NewApp(nil)
	NewExampleStack(app, "ExampleStack", &awscdk.StackProps{})
	app.Synth(nil)
}

Summary

  • API Gateway V2 (HTTP API) is cheaper and faster than API Gateway V1 (REST API).
  • There’s some good reasons to use HTTP adaptors with Lambda. You don’t need to make one massive Lambda function, you can still divide up your HTTP handlers per route.
  • API Gateway V2 support is still a bit hit and miss, despite it being two years old at the time of writing.
  • The payload format has changed, and some of the HTTP adaptors don’t work, even when using the backwards compatible payload formats.
  • I wrote an adapter for my use, and a PR for my favourite library, but in the meantime, I’m using my own thing.
  • Code is over at [9].