AWS Lambda and API Gateway
The AWS architecture to build APIs with a Lambda connected to an API Gateway is cheap and scales to the moon, but a development environment is not as simple as building a copy of your service, starting it up, and making API calls to localhost.
There is no host for a Lambda, at least not one you manage. So how to build and test?
Background
Architecting Errthquake, the Lambda / API Gateway combination had the cost/scale I wanted. More considerations:
For a load and stress testing service, the API to control it had to be reliable without a lot of attention, because the test service itself is where most of the engineering needed to go. Happy to let AWS manage this.
AWS has v1 and v2 versions of the API Gateway request/response objects. I’ll be uploading and downloading files, so I need to use v1 because v2 assumes your response will always be a json document.
I’m writing the test service in Go, so I’ll also write the Lambda functions in Go. Performance isn’t as much a consideration here as working in Go without switching languages.
Lambdas call an event handler when they’re invoked, more like executing from a standing start than entering a service loop that handles requests. The code to write a Go handler looks like this,
package main import ( "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" ) func HandleRequest(ctx context.Context, event *events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { contentResponse := events.APIGatewayProxyResponse{ StatusCode: 200, Headers: map[string]string{"Content-Type": "application/json"}, Body: "{\"message\":\"hello world\"}", } return contentResponse, nil } func main() { lambda.Start(HandleRequest) }
API Gateway Handlers
Local Development
AWS uses an executable named “bootstrap” in their examples, we'll use it for our own lambda too. Break the Go program into two files, bootstrap.go and lambda.go
bootstrap.go
package main import ( "github.com/aws/aws-lambda-go/lambda" ) func main() { lambda.Start(HandleRequest) }
lambda.go
package main import ( "github.com/aws/aws-lambda-go/events" ) func HandleRequest(ctx context.Context, event *events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { contentResponse := events.APIGatewayProxyResponse{ StatusCode: 200, Headers: map[string]string{"Content-Type": "application/json"}, Body: "{\"message\":\"hello world\"}", } return contentResponse, nil }
Add a third file, exec.go. This one takes a json file with the document structure of an API Gateway v1 object, turns it into an APIGatewayProxyRequest object, and likewise turns the APIGatewayProxyResponse object into json for output.
package main import ( "bufio" "context" "encoding/json" "fmt" "io" "log" "os" "github.com/aws/aws-lambda-go/events" ) func main() { log.SetFlags(0) // no date or time, just use log to write to stderr if len(os.Args) != 2 { log.Fatalf("submit a json file with this test\n") } var file *os.File var err error if file, err = os.Open(os.Args[1]); err != nil { log.Fatalf("error opening file %s: %s\n", os.Args[1], err) } defer file.Close() var requestBytes []byte fileReader := bufio.NewReader(file) if requestBytes, err = io.ReadAll(fileReader); err != nil { log.Fatalf("error reading file %s: %s\n", os.Args[1], err) } log.Printf("%s\n", requestBytes) var proxyRequest events.APIGatewayProxyRequest if err = json.Unmarshal(requestBytes, &proxyRequest); err != nil { log.Fatalf("error building APIGatewayProxyRequest from file %s: %s\n", os.Args[1], err) } var proxyResponse events.APIGatewayProxyResponse if proxyResponse, err = HandleRequest(context.TODO(), &proxyRequest); err != nil { log.Fatalf("error building APIGatewayProxyResponse for %s: %s\n", os.Args[1], err) } var responseBytes []byte if responseBytes, err = json.Marshal(proxyResponse); err != nil { log.Fatalf("error building response json from APIGatewayProxyResponse object: %s\n", err) } fmt.Printf("%s\n", responseBytes) }
Local Testing
Build the executable
go build -o exec exec.go lambda.go
Testing requires a json file. The AWS API Gateway v1 payload format example has a representative document that you can fill in with your own API path, headers, and parameters.
$ ./exec my-example-api-call.json
Building for Lambda Upload
Lambda gives a choice between x86 and ARM 64-bit architectures. Match the lambda architecture to your GOARCH value here. GOOS should be Linux.
$ GOARCH=arm64 GOOS=linux go build -o bootstrap -tags lambda.norpc bootstrap.go lambda.go $ zip -FS my-api-lambda.zip bootstrap $ aws lambda update-function-code --function-name my-api-lambda --zip-file fileb://$PWD/my-api-lambda.zip