From 2679a89f15604c2e8c126bf7a53320f1f954c368 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Mon, 17 Apr 2023 07:16:46 -0700 Subject: [PATCH 1/8] initial --- lambdaurl/http_handler.go | 111 ++++++++++++++ lambdaurl/http_handler_test.go | 141 ++++++++++++++++++ ...omain-only-get-request-trailing-slash.json | 45 ++++++ .../function-url-domain-only-get-request.json | 45 ++++++ ...only-request-with-base64-encoded-body.json | 47 ++++++ ...ith-headers-and-cookies-and-text-body.json | 59 ++++++++ lambdaurl/testdata/gen-events.sh | 54 +++++++ lambdaurl/testdata/testfunc/.gitignore | 2 + lambdaurl/testdata/testfunc/echo/main.go | 11 ++ lambdaurl/testdata/testfunc/go.mod | 7 + lambdaurl/testdata/testfunc/go.sum | 4 + lambdaurl/testdata/testfunc/site/main.go | 39 +++++ lambdaurl/testdata/testfunc/template.yaml | 37 +++++ 13 files changed, 602 insertions(+) create mode 100644 lambdaurl/http_handler.go create mode 100644 lambdaurl/http_handler_test.go create mode 100644 lambdaurl/testdata/function-url-domain-only-get-request-trailing-slash.json create mode 100644 lambdaurl/testdata/function-url-domain-only-get-request.json create mode 100644 lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json create mode 100644 lambdaurl/testdata/function-url-request-with-headers-and-cookies-and-text-body.json create mode 100644 lambdaurl/testdata/gen-events.sh create mode 100644 lambdaurl/testdata/testfunc/.gitignore create mode 100644 lambdaurl/testdata/testfunc/echo/main.go create mode 100644 lambdaurl/testdata/testfunc/go.mod create mode 100644 lambdaurl/testdata/testfunc/go.sum create mode 100644 lambdaurl/testdata/testfunc/site/main.go create mode 100644 lambdaurl/testdata/testfunc/template.yaml diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go new file mode 100644 index 00000000..6624c578 --- /dev/null +++ b/lambdaurl/http_handler.go @@ -0,0 +1,111 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Package lambdaurl serves requests from Lambda Function URLs using http.Handler. +package lambdaurl + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "strings" + "sync" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" +) + +type httpResponseWriter struct { + header http.Header + writer io.Writer + once sync.Once + status chan<- int +} + +func (w *httpResponseWriter) Header() http.Header { + return w.header +} + +func (w *httpResponseWriter) Write(p []byte) (int, error) { + w.once.Do(func() { w.status <- http.StatusOK }) + return w.writer.Write(p) +} + +func (w *httpResponseWriter) WriteHeader(statusCode int) { + w.once.Do(func() { w.status <- statusCode }) +} + +func toError(v any) error { + if v == nil { + return nil + } + if v, ok := v.(error); ok { + return v + } + return fmt.Errorf("%v", v) +} + +type requestContextKey struct{} + +// RequestFromContext returns the *events.LambdaFunctionURLRequest from a context. +func RequestFromContext(ctx context.Context) (*events.LambdaFunctionURLRequest, bool) { + req, ok := ctx.Value(requestContextKey{}).(*events.LambdaFunctionURLRequest) + return req, ok +} + +// Wrap converts an http.Handler into a lambda request handler. +// Only Lambda Function URLs configured with `InvokeMode: RESPONSE_STREAM` are supported with the returned handler. +// The response body of the handler will conform the the content-type `application/vnd.awslambda.http-integration-response` +func Wrap(handler http.Handler) func(context.Context, *events.LambdaFunctionURLRequest) (*events.LambdaFunctionURLStreamingResponse, error) { + return func(ctx context.Context, request *events.LambdaFunctionURLRequest) (*events.LambdaFunctionURLStreamingResponse, error) { + var body io.Reader = strings.NewReader(request.Body) + if request.IsBase64Encoded { + body = base64.NewDecoder(base64.StdEncoding, body) + } + url := "https://" + request.RequestContext.DomainName + request.RawPath + if request.RawQueryString != "" { + url += "?" + request.RawQueryString + } + ctx = context.WithValue(ctx, requestContextKey{}, request) + httpRequest, err := http.NewRequestWithContext(ctx, request.RequestContext.HTTP.Method, url, body) + if err != nil { + return nil, err + } + for k, v := range request.Headers { + httpRequest.Header.Add(k, v) + } + status := make(chan int) // Signals when it's OK to start returning the response body to Lambda + header := http.Header{} + r, w := io.Pipe() + go func() { + defer close(status) + // TODO: should add metadata to the error to tell the invoke loop to exit after reporting the error + defer w.CloseWithError(toError(recover())) + handler.ServeHTTP(&httpResponseWriter{writer: w, header: header, status: status}, httpRequest) + }() + response := &events.LambdaFunctionURLStreamingResponse{ + Body: r, + StatusCode: <-status, + } + if len(header) > 0 { + response.Headers = make(map[string]string, len(header)) + for k, v := range header { + if k == "Set-Cookie" { + response.Cookies = v + } else { + response.Headers[k] = strings.Join(v, ",") + } + } + } + return response, nil + } +} + +// Start converts wraps a http.Handler and calls lambda.StartHandlerFunc +// Only supports: +// - Lambda Function URLs configured with `InvokeMode: RESPONSE_STREAM` +// - Lambda Functions using the `provided` or `provided.al2` runtimes. +// - Lambda Functions using the `go1.x` runtime when compiled with `-tags lambda.norpc` +func Start(handler http.Handler, options ...lambda.Option) { + lambda.StartHandlerFunc(Wrap(handler), options...) +} diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go new file mode 100644 index 00000000..cc23144a --- /dev/null +++ b/lambdaurl/http_handler_test.go @@ -0,0 +1,141 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +package lambdaurl + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "io" + "io/ioutil" + "log" + "net/http" + "testing" + "time" + + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed testdata/function-url-request-with-headers-and-cookies-and-text-body.json +var helloRequest []byte + +//go:embed testdata/function-url-domain-only-get-request.json +var domainOnlyGetRequest []byte + +//go:embed testdata/function-url-domain-only-get-request-trailing-slash.json +var domainOnlyWithSlashGetRequest []byte + +//go:embed testdata/function-url-domain-only-request-with-base64-encoded-body.json +var base64EncodedBodyRequest []byte + +func TestWrap(t *testing.T) { + for name, params := range map[string]struct { + input []byte + handler http.HandlerFunc + expectStatus int + expectBody string + expectHeaders map[string]string + expectCookies []string + }{ + "hello": { + input: helloRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Hello", "world1") + w.Header().Add("Hello", "world2") + http.SetCookie(w, &http.Cookie{Name: "yummy", Value: "cookie"}) + http.SetCookie(w, &http.Cookie{Name: "yummy", Value: "cake"}) + http.SetCookie(w, &http.Cookie{Name: "fruit", Value: "banana", Expires: time.Date(2000, time.January, 0, 0, 0, 0, 0, time.UTC)}) + for _, c := range r.Cookies() { + http.SetCookie(w, c) + } + + w.WriteHeader(http.StatusTeapot) + encoder := json.NewEncoder(w) + _ = encoder.Encode(struct{ RequestQueryParams, Method any }{r.URL.Query(), r.Method}) + }, + expectStatus: http.StatusTeapot, + expectHeaders: map[string]string{ + "Hello": "world1,world2", + }, + expectCookies: []string{ + "yummy=cookie", + "yummy=cake", + "fruit=banana; Expires=Fri, 31 Dec 1999 00:00:00 GMT", + "foo=bar", + "hello=hello", + }, + expectBody: `{"RequestQueryParams":{"foo":["bar"],"hello":["world"]},"Method":"POST"}` + "\n", + }, + "mux": { + input: helloRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + log.Println(r.URL) + mux := http.NewServeMux() + mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte("Hello World!")) + }) + mux.ServeHTTP(w, r) + }, + expectStatus: 200, + expectBody: "Hello World!", + }, + "get-implicit-trailing-slash": { + input: domainOnlyGetRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + encoder := json.NewEncoder(w) + _ = encoder.Encode(r.Method) + _ = encoder.Encode(r.URL.String()) + }, + expectStatus: http.StatusOK, + expectBody: "\"GET\"\n\"https://lambda-url-id.lambda-url.us-west-2.on.aws/\"\n", + }, + "get-explicit-trailing-slash": { + input: domainOnlyWithSlashGetRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + encoder := json.NewEncoder(w) + _ = encoder.Encode(r.Method) + _ = encoder.Encode(r.URL.String()) + }, + expectStatus: http.StatusOK, + expectBody: "\"GET\"\n\"https://lambda-url-id.lambda-url.us-west-2.on.aws/\"\n", + }, + "empty handler": { + input: helloRequest, + handler: func(w http.ResponseWriter, r *http.Request) {}, + expectStatus: http.StatusOK, + }, + "base64request": { + input: base64EncodedBodyRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(w, r.Body) + }, + expectStatus: http.StatusOK, + expectBody: "", + }, + } { + t.Run(name, func(t *testing.T) { + handler := Wrap(params.handler) + var req events.LambdaFunctionURLRequest + require.NoError(t, json.Unmarshal(params.input, &req)) + res, err := handler(context.Background(), &req) + require.NoError(t, err) + resultBodyBytes, err := ioutil.ReadAll(res) + require.NoError(t, err) + resultHeaderBytes, resultBodyBytes, ok := bytes.Cut(resultBodyBytes, []byte{0, 0, 0, 0, 0, 0, 0, 0}) + require.True(t, ok) + var resultHeader struct { + StatusCode int + Headers map[string]string + Cookies []string + } + require.NoError(t, json.Unmarshal(resultHeaderBytes, &resultHeader)) + assert.Equal(t, params.expectBody, string(resultBodyBytes)) + assert.Equal(t, params.expectStatus, resultHeader.StatusCode) + assert.Equal(t, params.expectHeaders, resultHeader.Headers) + assert.Equal(t, params.expectCookies, resultHeader.Cookies) + }) + } +} diff --git a/lambdaurl/testdata/function-url-domain-only-get-request-trailing-slash.json b/lambdaurl/testdata/function-url-domain-only-get-request-trailing-slash.json new file mode 100644 index 00000000..e0d592c6 --- /dev/null +++ b/lambdaurl/testdata/function-url-domain-only-get-request-trailing-slash.json @@ -0,0 +1,45 @@ +{ + "headers": { + "accept": "application/xml", + "accept-encoding": "gzip, deflate", + "content-type": "application/json", + "host": "lambda-url-id.lambda-url.us-west-2.on.aws", + "user-agent": "python-requests/2.28.2", + "x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "x-amz-date": "20230418T161718Z", + "x-amz-security-token": "security-token", + "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", + "x-amzn-tls-version": "TLSv1.2", + "x-amzn-trace-id": "Root=1-643ec28e-091cdf8738a4a05e46b24cca", + "x-forwarded-for": "127.0.0.1", + "x-forwarded-port": "443", + "x-forwarded-proto": "https" + }, + "isBase64Encoded": false, + "rawPath": "/", + "rawQueryString": "", + "requestContext": { + "accountId": "aws-account-id", + "apiId": "lambda-url-id", + "authorizer": { + "iam": {} + }, + "domainName": "lambda-url-id.lambda-url.us-west-2.on.aws", + "domainPrefix": "lambda-url-id", + "http": { + "method": "GET", + "path": "/", + "protocol": "HTTP/1.1", + "sourceIp": "205.251.233.183", + "userAgent": "python-requests/2.28.2", + "source-ip": "127.0.0.1" + }, + "requestId": "2655e7f5-8516-45e3-9430-d3fe5806c739", + "routeKey": "$default", + "stage": "$default", + "time": "18/Apr/2023:16:17:18 +0000", + "timeEpoch": 1681834638207 + }, + "routeKey": "$default", + "version": "2.0" +} diff --git a/lambdaurl/testdata/function-url-domain-only-get-request.json b/lambdaurl/testdata/function-url-domain-only-get-request.json new file mode 100644 index 00000000..c1a8853a --- /dev/null +++ b/lambdaurl/testdata/function-url-domain-only-get-request.json @@ -0,0 +1,45 @@ +{ + "headers": { + "accept": "application/xml", + "accept-encoding": "gzip, deflate", + "content-type": "application/json", + "host": "lambda-url-id.lambda-url.us-west-2.on.aws", + "user-agent": "python-requests/2.28.2", + "x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "x-amz-date": "20230418T161717Z", + "x-amz-security-token": "security-token", + "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", + "x-amzn-tls-version": "TLSv1.2", + "x-amzn-trace-id": "Root=1-643ec28d-0d08646322e615d83f823e2f", + "x-forwarded-for": "127.0.0.1", + "x-forwarded-port": "443", + "x-forwarded-proto": "https" + }, + "isBase64Encoded": false, + "rawPath": "/", + "rawQueryString": "", + "requestContext": { + "accountId": "aws-account-id", + "apiId": "lambda-url-id", + "authorizer": { + "iam": {} + }, + "domainName": "lambda-url-id.lambda-url.us-west-2.on.aws", + "domainPrefix": "lambda-url-id", + "http": { + "method": "GET", + "path": "/", + "protocol": "HTTP/1.1", + "sourceIp": "205.251.233.183", + "userAgent": "python-requests/2.28.2", + "source-ip": "127.0.0.1" + }, + "requestId": "6023ba38-dd15-47e0-bc61-01658236e706", + "routeKey": "$default", + "stage": "$default", + "time": "18/Apr/2023:16:17:17 +0000", + "timeEpoch": 1681834637747 + }, + "routeKey": "$default", + "version": "2.0" +} diff --git a/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json b/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json new file mode 100644 index 00000000..c86e938b --- /dev/null +++ b/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json @@ -0,0 +1,47 @@ +{ + "body": "", + "headers": { + "accept": "application/xml", + "accept-encoding": "gzip, deflate", + "content-length": "6", + "content-type": "application/json", + "host": "lambda-url-id.lambda-url.us-west-2.on.aws", + "user-agent": "python-requests/2.28.2", + "x-amz-content-sha256": "0ab2082273499eaa495f2196e32d8c794745e58a20a0c93182c59d2165432839", + "x-amz-date": "20230418T161717Z", + "x-amz-security-token": "security-token", + "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", + "x-amzn-tls-version": "TLSv1.2", + "x-amzn-trace-id": "Root=1-643ec28d-22a56e4362dc2d4d607890d8", + "x-forwarded-for": "127.0.0.1", + "x-forwarded-port": "443", + "x-forwarded-proto": "https" + }, + "isBase64Encoded": false, + "rawPath": "/", + "rawQueryString": "", + "requestContext": { + "accountId": "aws-account-id", + "apiId": "lambda-url-id", + "authorizer": { + "iam": {} + }, + "domainName": "lambda-url-id.lambda-url.us-west-2.on.aws", + "domainPrefix": "lambda-url-id", + "http": { + "method": "POST", + "path": "/", + "protocol": "HTTP/1.1", + "sourceIp": "205.251.233.183", + "userAgent": "python-requests/2.28.2", + "source-ip": "127.0.0.1" + }, + "requestId": "338a2599-3a12-450b-af71-80ea3bd3bd72", + "routeKey": "$default", + "stage": "$default", + "time": "18/Apr/2023:16:17:17 +0000", + "timeEpoch": 1681834637521 + }, + "routeKey": "$default", + "version": "2.0" +} diff --git a/lambdaurl/testdata/function-url-request-with-headers-and-cookies-and-text-body.json b/lambdaurl/testdata/function-url-request-with-headers-and-cookies-and-text-body.json new file mode 100644 index 00000000..7fd9ad23 --- /dev/null +++ b/lambdaurl/testdata/function-url-request-with-headers-and-cookies-and-text-body.json @@ -0,0 +1,59 @@ +{ + "body": "{\"hello\": \"world\"}", + "cookies": [ + "foo=bar", + "hello=hello" + ], + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "content-length": "18", + "content-type": "application/json", + "cookie": "foo=bar; hello=hello", + "header1": "h1", + "header2": "h1,h2", + "header3": "h3", + "host": "lambda-url-id.lambda-url.us-west-2.on.aws", + "user-agent": "python-requests/2.28.2", + "x-amz-content-sha256": "5f8f04f6a3a892aaabbddb6cf273894493773960d4a325b105fee46eef4304f1", + "x-amz-date": "20230418T161716Z", + "x-amz-security-token": "security-token", + "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", + "x-amzn-tls-version": "TLSv1.2", + "x-amzn-trace-id": "Root=1-643ec28d-5503dc7274559e68706dae64", + "x-forwarded-for": "127.0.0.1", + "x-forwarded-port": "443", + "x-forwarded-proto": "https" + }, + "isBase64Encoded": false, + "queryStringParameters": { + "foo": "bar", + "hello": "world" + }, + "rawPath": "/hello", + "rawQueryString": "hello=world&foo=bar", + "requestContext": { + "accountId": "aws-account-id", + "apiId": "lambda-url-id", + "authorizer": { + "iam": {} + }, + "domainName": "lambda-url-id.lambda-url.us-west-2.on.aws", + "domainPrefix": "lambda-url-id", + "http": { + "method": "POST", + "path": "/hello", + "protocol": "HTTP/1.1", + "sourceIp": "205.251.233.183", + "userAgent": "python-requests/2.28.2", + "source-ip": "127.0.0.1" + }, + "requestId": "28d04d7e-7f0b-4521-9484-f7012b07cb68", + "routeKey": "$default", + "stage": "$default", + "time": "18/Apr/2023:16:17:17 +0000", + "timeEpoch": 1681834637081 + }, + "routeKey": "$default", + "version": "2.0" +} diff --git a/lambdaurl/testdata/gen-events.sh b/lambdaurl/testdata/gen-events.sh new file mode 100644 index 00000000..7df566bd --- /dev/null +++ b/lambdaurl/testdata/gen-events.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +url_id="${1}" # should be the lambda function url domain prefix for an echo function +region=${AWS_REGION:-us-west-2} +url="https://${url_id}.lambda-url.${region}.on.aws" +account_id=$(aws sts get-caller-identity --output text --query "Account") + +redact () { + #https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html#identifiers-unique-ids + sed "s/${url_id}/lambda-url-id/g" \ + | sed 's/A[A-Z][A-Z]A[A-Z1-9]*\([":]\)/iam-unique-id\1/g' \ + | sed "s/${account_id}/aws-account-id/g" \ + | jq '.headers |= (.["x-amz-security-token"] = "security-token" )' \ + | jq '.headers |= (.["x-forwarded-for"] = "127.0.0.1")' \ + | jq '.requestContext.authorizer |= (.["iam"] = {})' \ + | jq '.requestContext.http |= (.["source-ip"] = "127.0.0.1")' +} + +awscurl --service lambda --region $region \ + -X POST \ + -H 'Header1: h1' \ + -H 'Header2: h1,h2' \ + -H 'Header3: h3' \ + -H 'Cookie: foo=bar; hello=hello' \ + -H 'Content-Type: application/json' \ + -d '{"hello": "world"}' \ + "$url/hello?hello=world&foo=bar" \ + | redact \ + | tee function-url-request-with-headers-and-cookies-and-text-body.json \ + | jq + +awscurl --service lambda --region $region \ + -X POST \ + -d '' \ + "$url" \ + | redact \ + | tee function-url-domain-only-request-with-base64-encoded-body.json \ + | jq + +awscurl --service lambda --region $region \ + -X GET \ + "$url" \ + | redact \ + | tee function-url-domain-only-get-request.json \ + | jq + +awscurl --service lambda --region $region \ + -X GET \ + "$url/" \ + | redact \ + | tee function-url-domain-only-get-request-trailing-slash.json \ + | jq + diff --git a/lambdaurl/testdata/testfunc/.gitignore b/lambdaurl/testdata/testfunc/.gitignore new file mode 100644 index 00000000..015e99b6 --- /dev/null +++ b/lambdaurl/testdata/testfunc/.gitignore @@ -0,0 +1,2 @@ +.aws-sam/ +samconfig.toml diff --git a/lambdaurl/testdata/testfunc/echo/main.go b/lambdaurl/testdata/testfunc/echo/main.go new file mode 100644 index 00000000..0f86ea83 --- /dev/null +++ b/lambdaurl/testdata/testfunc/echo/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "github.com/aws/aws-lambda-go/lambda" +) + +func main() { + lambda.Start(func(req any) (any, error) { + return req, nil + }) +} diff --git a/lambdaurl/testdata/testfunc/go.mod b/lambdaurl/testdata/testfunc/go.mod new file mode 100644 index 00000000..c5b365c1 --- /dev/null +++ b/lambdaurl/testdata/testfunc/go.mod @@ -0,0 +1,7 @@ +module testfunc + +require github.com/aws/aws-lambda-go v1.40.0 + +replace github.com/aws/aws-lambda-go => ../../../ + +go 1.20 diff --git a/lambdaurl/testdata/testfunc/go.sum b/lambdaurl/testdata/testfunc/go.sum new file mode 100644 index 00000000..8c60f088 --- /dev/null +++ b/lambdaurl/testdata/testfunc/go.sum @@ -0,0 +1,4 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/lambdaurl/testdata/testfunc/site/main.go b/lambdaurl/testdata/testfunc/site/main.go new file mode 100644 index 00000000..2bcf4dca --- /dev/null +++ b/lambdaurl/testdata/testfunc/site/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "strings" + + "github.com/aws/aws-lambda-go/lambdaurl" +) + +func logLambdaRequest(ctx context.Context) { + req, ok := lambdaurl.RequestFromContext(ctx) + if ok { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(req) + } +} + +func root(w http.ResponseWriter, r *http.Request) { + logLambdaRequest(r.Context()) +} + +func hello(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.Copy(w, strings.NewReader(`Hello World!`)) +} + +func main() { + http.HandleFunc("/hello", hello) + http.HandleFunc("/", root) + if os.Getenv("AWS_LAMBDA_RUNTIME_API") != "" { + lambdaurl.Start(http.DefaultServeMux) + } + http.ListenAndServe(":9001", nil) +} diff --git a/lambdaurl/testdata/testfunc/template.yaml b/lambdaurl/testdata/testfunc/template.yaml new file mode 100644 index 00000000..8cce6816 --- /dev/null +++ b/lambdaurl/testdata/testfunc/template.yaml @@ -0,0 +1,37 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + Test handler for github.com/aws/aws-lambda-go/lambdaurl +Globals: + Function: + Timeout: 3 + Runtime: provided.al2 + Handler: bootstrap + Architectures: [ arm64 ] +Resources: + Site: + Type: AWS::Serverless::Function + Metadata: + BuildMethod: go1.x + Properties: + CodeUri: site + FunctionUrlConfig: + AuthType: AWS_IAM + InvokeMode: RESPONSE_STREAM + Echo: + Type: AWS::Serverless::Function + Metadata: + BuildMethod: go1.x + Properties: + CodeUri: echo + FunctionUrlConfig: + AuthType: AWS_IAM + InvokeMode: RESPONSE_STREAM + +Outputs: + SiteUrl: + Description: "Site Lambda Function URL" + Value: !GetAtt SiteUrl.FunctionUrl + EchoUrl: + Description: "Echo Lambda Function URL" + Value: !GetAtt EchoUrl.FunctionUrl From 4befe76260ca4a6c3717765bc18d50de6b96b098 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Tue, 18 Apr 2023 10:02:03 -0700 Subject: [PATCH 2/8] typo in events redaction --- ...rl-domain-only-get-request-trailing-slash.json | 15 +++++++-------- .../function-url-domain-only-get-request.json | 15 +++++++-------- ...ain-only-request-with-base64-encoded-body.json | 15 +++++++-------- ...st-with-headers-and-cookies-and-text-body.json | 15 +++++++-------- lambdaurl/testdata/gen-events.sh | 2 +- 5 files changed, 29 insertions(+), 33 deletions(-) diff --git a/lambdaurl/testdata/function-url-domain-only-get-request-trailing-slash.json b/lambdaurl/testdata/function-url-domain-only-get-request-trailing-slash.json index e0d592c6..1bb6a1bb 100644 --- a/lambdaurl/testdata/function-url-domain-only-get-request-trailing-slash.json +++ b/lambdaurl/testdata/function-url-domain-only-get-request-trailing-slash.json @@ -6,11 +6,11 @@ "host": "lambda-url-id.lambda-url.us-west-2.on.aws", "user-agent": "python-requests/2.28.2", "x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "x-amz-date": "20230418T161718Z", + "x-amz-date": "20230418T170147Z", "x-amz-security-token": "security-token", "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", "x-amzn-tls-version": "TLSv1.2", - "x-amzn-trace-id": "Root=1-643ec28e-091cdf8738a4a05e46b24cca", + "x-amzn-trace-id": "Root=1-643eccfb-7c4d3f09749a95a044db997a", "x-forwarded-for": "127.0.0.1", "x-forwarded-port": "443", "x-forwarded-proto": "https" @@ -30,15 +30,14 @@ "method": "GET", "path": "/", "protocol": "HTTP/1.1", - "sourceIp": "205.251.233.183", - "userAgent": "python-requests/2.28.2", - "source-ip": "127.0.0.1" + "sourceIp": "127.0.0.1", + "userAgent": "python-requests/2.28.2" }, - "requestId": "2655e7f5-8516-45e3-9430-d3fe5806c739", + "requestId": "3a72f39b-d6bd-4a4f-b040-f94d09b4daa3", "routeKey": "$default", "stage": "$default", - "time": "18/Apr/2023:16:17:18 +0000", - "timeEpoch": 1681834638207 + "time": "18/Apr/2023:17:01:47 +0000", + "timeEpoch": 1681837307717 }, "routeKey": "$default", "version": "2.0" diff --git a/lambdaurl/testdata/function-url-domain-only-get-request.json b/lambdaurl/testdata/function-url-domain-only-get-request.json index c1a8853a..31cfb536 100644 --- a/lambdaurl/testdata/function-url-domain-only-get-request.json +++ b/lambdaurl/testdata/function-url-domain-only-get-request.json @@ -6,11 +6,11 @@ "host": "lambda-url-id.lambda-url.us-west-2.on.aws", "user-agent": "python-requests/2.28.2", "x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "x-amz-date": "20230418T161717Z", + "x-amz-date": "20230418T170147Z", "x-amz-security-token": "security-token", "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", "x-amzn-tls-version": "TLSv1.2", - "x-amzn-trace-id": "Root=1-643ec28d-0d08646322e615d83f823e2f", + "x-amzn-trace-id": "Root=1-643eccfb-4c9be61972302fa41111a443", "x-forwarded-for": "127.0.0.1", "x-forwarded-port": "443", "x-forwarded-proto": "https" @@ -30,15 +30,14 @@ "method": "GET", "path": "/", "protocol": "HTTP/1.1", - "sourceIp": "205.251.233.183", - "userAgent": "python-requests/2.28.2", - "source-ip": "127.0.0.1" + "sourceIp": "127.0.0.1", + "userAgent": "python-requests/2.28.2" }, - "requestId": "6023ba38-dd15-47e0-bc61-01658236e706", + "requestId": "deeb7e49-a9a8-4a8f-bcd1-5482231e2087", "routeKey": "$default", "stage": "$default", - "time": "18/Apr/2023:16:17:17 +0000", - "timeEpoch": 1681834637747 + "time": "18/Apr/2023:17:01:47 +0000", + "timeEpoch": 1681837307545 }, "routeKey": "$default", "version": "2.0" diff --git a/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json b/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json index c86e938b..e5ef77ca 100644 --- a/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json +++ b/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json @@ -8,11 +8,11 @@ "host": "lambda-url-id.lambda-url.us-west-2.on.aws", "user-agent": "python-requests/2.28.2", "x-amz-content-sha256": "0ab2082273499eaa495f2196e32d8c794745e58a20a0c93182c59d2165432839", - "x-amz-date": "20230418T161717Z", + "x-amz-date": "20230418T170147Z", "x-amz-security-token": "security-token", "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", "x-amzn-tls-version": "TLSv1.2", - "x-amzn-trace-id": "Root=1-643ec28d-22a56e4362dc2d4d607890d8", + "x-amzn-trace-id": "Root=1-643eccfb-7fdecb844a12b4b45645132d", "x-forwarded-for": "127.0.0.1", "x-forwarded-port": "443", "x-forwarded-proto": "https" @@ -32,15 +32,14 @@ "method": "POST", "path": "/", "protocol": "HTTP/1.1", - "sourceIp": "205.251.233.183", - "userAgent": "python-requests/2.28.2", - "source-ip": "127.0.0.1" + "sourceIp": "127.0.0.1", + "userAgent": "python-requests/2.28.2" }, - "requestId": "338a2599-3a12-450b-af71-80ea3bd3bd72", + "requestId": "9701a3d4-36ad-40bd-bf0b-a525c987d27f", "routeKey": "$default", "stage": "$default", - "time": "18/Apr/2023:16:17:17 +0000", - "timeEpoch": 1681834637521 + "time": "18/Apr/2023:17:01:47 +0000", + "timeEpoch": 1681837307386 }, "routeKey": "$default", "version": "2.0" diff --git a/lambdaurl/testdata/function-url-request-with-headers-and-cookies-and-text-body.json b/lambdaurl/testdata/function-url-request-with-headers-and-cookies-and-text-body.json index 7fd9ad23..1f2dda51 100644 --- a/lambdaurl/testdata/function-url-request-with-headers-and-cookies-and-text-body.json +++ b/lambdaurl/testdata/function-url-request-with-headers-and-cookies-and-text-body.json @@ -16,11 +16,11 @@ "host": "lambda-url-id.lambda-url.us-west-2.on.aws", "user-agent": "python-requests/2.28.2", "x-amz-content-sha256": "5f8f04f6a3a892aaabbddb6cf273894493773960d4a325b105fee46eef4304f1", - "x-amz-date": "20230418T161716Z", + "x-amz-date": "20230418T170146Z", "x-amz-security-token": "security-token", "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", "x-amzn-tls-version": "TLSv1.2", - "x-amzn-trace-id": "Root=1-643ec28d-5503dc7274559e68706dae64", + "x-amzn-trace-id": "Root=1-643eccfa-2c6028925c2b249524664087", "x-forwarded-for": "127.0.0.1", "x-forwarded-port": "443", "x-forwarded-proto": "https" @@ -44,15 +44,14 @@ "method": "POST", "path": "/hello", "protocol": "HTTP/1.1", - "sourceIp": "205.251.233.183", - "userAgent": "python-requests/2.28.2", - "source-ip": "127.0.0.1" + "sourceIp": "127.0.0.1", + "userAgent": "python-requests/2.28.2" }, - "requestId": "28d04d7e-7f0b-4521-9484-f7012b07cb68", + "requestId": "5bbd0e3e-fe7a-4299-9076-32d4de45391b", "routeKey": "$default", "stage": "$default", - "time": "18/Apr/2023:16:17:17 +0000", - "timeEpoch": 1681834637081 + "time": "18/Apr/2023:17:01:46 +0000", + "timeEpoch": 1681837306806 }, "routeKey": "$default", "version": "2.0" diff --git a/lambdaurl/testdata/gen-events.sh b/lambdaurl/testdata/gen-events.sh index 7df566bd..642af14a 100644 --- a/lambdaurl/testdata/gen-events.sh +++ b/lambdaurl/testdata/gen-events.sh @@ -14,7 +14,7 @@ redact () { | jq '.headers |= (.["x-amz-security-token"] = "security-token" )' \ | jq '.headers |= (.["x-forwarded-for"] = "127.0.0.1")' \ | jq '.requestContext.authorizer |= (.["iam"] = {})' \ - | jq '.requestContext.http |= (.["source-ip"] = "127.0.0.1")' + | jq '.requestContext.http |= (.["sourceIp"] = "127.0.0.1")' } awscurl --service lambda --region $region \ From 00482bd5a14718cc7f980ec68ee0e6b40ca6c623 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Tue, 18 Apr 2023 11:34:59 -0700 Subject: [PATCH 3/8] 1.18+ --- lambdaurl/http_handler.go | 4 ++++ lambdaurl/http_handler_test.go | 3 +++ 2 files changed, 7 insertions(+) diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index 6624c578..5d38d77a 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -1,4 +1,8 @@ +//go:build go1.18 +// +build go1.18 + // Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + // Package lambdaurl serves requests from Lambda Function URLs using http.Handler. package lambdaurl diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index cc23144a..5ade8436 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -1,3 +1,6 @@ +//go:build go1.18 +// +build go1.18 + // Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. package lambdaurl From 5f23c47ac276c71a3af4f8ef60fb25e1d8d1a352 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Wed, 19 Apr 2023 15:55:13 -0700 Subject: [PATCH 4/8] remove the panic recover for now - the runtime api client code does not yet re-propogate the crash --- lambdaurl/http_handler.go | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index 5d38d77a..7d88ade6 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -9,7 +9,6 @@ package lambdaurl import ( "context" "encoding/base64" - "fmt" "io" "net/http" "strings" @@ -39,16 +38,6 @@ func (w *httpResponseWriter) WriteHeader(statusCode int) { w.once.Do(func() { w.status <- statusCode }) } -func toError(v any) error { - if v == nil { - return nil - } - if v, ok := v.(error); ok { - return v - } - return fmt.Errorf("%v", v) -} - type requestContextKey struct{} // RequestFromContext returns the *events.LambdaFunctionURLRequest from a context. @@ -83,8 +72,7 @@ func Wrap(handler http.Handler) func(context.Context, *events.LambdaFunctionURLR r, w := io.Pipe() go func() { defer close(status) - // TODO: should add metadata to the error to tell the invoke loop to exit after reporting the error - defer w.CloseWithError(toError(recover())) + defer w.Close() // TODO: recover and CloseWithError the any panic value once the runtime API client supports plumbing fatal errors through the reader handler.ServeHTTP(&httpResponseWriter{writer: w, header: header, status: status}, httpRequest) }() response := &events.LambdaFunctionURLStreamingResponse{ From e84b006b96b2bc351d5cf114be4741a6a508084b Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 20 Apr 2023 12:36:22 -0700 Subject: [PATCH 5/8] Fix typo Co-authored-by: Aidan Steele --- lambdaurl/http_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index 7d88ade6..8111a6dd 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -48,7 +48,7 @@ func RequestFromContext(ctx context.Context) (*events.LambdaFunctionURLRequest, // Wrap converts an http.Handler into a lambda request handler. // Only Lambda Function URLs configured with `InvokeMode: RESPONSE_STREAM` are supported with the returned handler. -// The response body of the handler will conform the the content-type `application/vnd.awslambda.http-integration-response` +// The response body of the handler will conform to the content-type `application/vnd.awslambda.http-integration-response` func Wrap(handler http.Handler) func(context.Context, *events.LambdaFunctionURLRequest) (*events.LambdaFunctionURLStreamingResponse, error) { return func(ctx context.Context, request *events.LambdaFunctionURLRequest) (*events.LambdaFunctionURLStreamingResponse, error) { var body io.Reader = strings.NewReader(request.Body) From e2555f89f913ebdb0a2ef47abf27644b48bf8be8 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Sun, 23 Apr 2023 14:39:48 -0700 Subject: [PATCH 6/8] Update http_handler.go --- lambdaurl/http_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index 8111a6dd..70e73d0a 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -93,7 +93,7 @@ func Wrap(handler http.Handler) func(context.Context, *events.LambdaFunctionURLR } } -// Start converts wraps a http.Handler and calls lambda.StartHandlerFunc +// Start wraps a http.Handler and calls lambda.StartHandlerFunc // Only supports: // - Lambda Function URLs configured with `InvokeMode: RESPONSE_STREAM` // - Lambda Functions using the `provided` or `provided.al2` runtimes. From 787a3c214f6fbc171fb538ebe8ee6344d9bc0d40 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Sun, 23 Apr 2023 14:59:00 -0700 Subject: [PATCH 7/8] base64 decode branch coverage --- ...-url-domain-only-request-with-base64-encoded-body.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json b/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json index e5ef77ca..121b7b56 100644 --- a/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json +++ b/lambdaurl/testdata/function-url-domain-only-request-with-base64-encoded-body.json @@ -1,10 +1,10 @@ { - "body": "", + "body": "PGlkay8+", "headers": { - "accept": "application/xml", + "accept": "*/*", "accept-encoding": "gzip, deflate", "content-length": "6", - "content-type": "application/json", + "content-type": "idk", "host": "lambda-url-id.lambda-url.us-west-2.on.aws", "user-agent": "python-requests/2.28.2", "x-amz-content-sha256": "0ab2082273499eaa495f2196e32d8c794745e58a20a0c93182c59d2165432839", @@ -17,7 +17,7 @@ "x-forwarded-port": "443", "x-forwarded-proto": "https" }, - "isBase64Encoded": false, + "isBase64Encoded": true, "rawPath": "/", "rawQueryString": "", "requestContext": { From 16507632ab5f01946eb23fbd4741c9b2eab8c0fb Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Sun, 23 Apr 2023 15:24:04 -0700 Subject: [PATCH 8/8] cover RequestFromContext --- lambdaurl/http_handler_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index 5ade8436..a6e6aa8d 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -142,3 +142,16 @@ func TestWrap(t *testing.T) { }) } } + +func TestRequestContext(t *testing.T) { + var req *events.LambdaFunctionURLRequest + require.NoError(t, json.Unmarshal(helloRequest, &req)) + handler := Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqFromContext, exists := RequestFromContext(r.Context()) + require.True(t, exists) + require.NotNil(t, reqFromContext) + assert.Equal(t, req, reqFromContext) + })) + _, err := handler(context.Background(), req) + require.NoError(t, err) +}