From cd5421417b6418d8e3f39a2cac89cf2486c0a2c2 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Tue, 16 May 2023 10:50:00 -0700 Subject: [PATCH 01/13] Set a default Content-Type header for lambdaurl.Wrap --- lambdaurl/http_handler.go | 28 +++++++++++++++---- lambdaurl/http_handler_test.go | 50 +++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index 70e73d0a..5512aec1 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -30,12 +30,24 @@ func (w *httpResponseWriter) Header() http.Header { } func (w *httpResponseWriter) Write(p []byte) (int, error) { - w.once.Do(func() { w.status <- http.StatusOK }) + w.once.Do(func() { + w.detectContentType(p) + w.status <- http.StatusOK + }) return w.writer.Write(p) } func (w *httpResponseWriter) WriteHeader(statusCode int) { - w.once.Do(func() { w.status <- statusCode }) + w.once.Do(func() { + w.detectContentType(nil) + w.status <- statusCode + }) +} + +func (w *httpResponseWriter) detectContentType(p []byte) { + if w.header.Get("Content-Type") == "" { + w.header.Set("Content-Type", http.DetectContentType(p)) + } } type requestContextKey struct{} @@ -46,9 +58,13 @@ func RequestFromContext(ctx context.Context) (*events.LambdaFunctionURLRequest, return req, ok } -// Wrap converts an http.Handler into a lambda request handler. +// 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 to 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`. +// +// Note: The http.ResponseWriter passed to the handler is unbuffered. +// This may result in different Content-Type and Content-Length headers in the Function URL response when compared to http.ListenAndServe. 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) @@ -73,7 +89,9 @@ func Wrap(handler http.Handler) func(context.Context, *events.LambdaFunctionURLR go func() { defer close(status) 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) + responseWriter := &httpResponseWriter{writer: w, header: header, status: status} + defer responseWriter.Write(nil) + handler.ServeHTTP(responseWriter, httpRequest) }() response := &events.LambdaFunctionURLStreamingResponse{ Body: r, diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index a6e6aa8d..b6c4cbd0 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -60,7 +60,8 @@ func TestWrap(t *testing.T) { }, expectStatus: http.StatusTeapot, expectHeaders: map[string]string{ - "Hello": "world1,world2", + "Hello": "world1,world2", + "Content-Type": "text/plain; charset=utf-8", }, expectCookies: []string{ "yummy=cookie", @@ -82,6 +83,9 @@ func TestWrap(t *testing.T) { }) mux.ServeHTTP(w, r) }, + expectHeaders: map[string]string{ + "Content-Type": "text/plain; charset=utf-8", + }, expectStatus: 200, expectBody: "Hello World!", }, @@ -94,6 +98,9 @@ func TestWrap(t *testing.T) { }, expectStatus: http.StatusOK, expectBody: "\"GET\"\n\"https://lambda-url-id.lambda-url.us-west-2.on.aws/\"\n", + expectHeaders: map[string]string{ + "Content-Type": "text/plain; charset=utf-8", + }, }, "get-explicit-trailing-slash": { input: domainOnlyWithSlashGetRequest, @@ -104,11 +111,27 @@ func TestWrap(t *testing.T) { }, expectStatus: http.StatusOK, expectBody: "\"GET\"\n\"https://lambda-url-id.lambda-url.us-west-2.on.aws/\"\n", + expectHeaders: map[string]string{ + "Content-Type": "text/plain; charset=utf-8", + }, }, "empty handler": { input: helloRequest, handler: func(w http.ResponseWriter, r *http.Request) {}, expectStatus: http.StatusOK, + expectHeaders: map[string]string{ + "Content-Type": "text/plain; charset=utf-8", + }, + }, + "write status code only": { + input: helloRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + expectStatus: http.StatusAccepted, + expectHeaders: map[string]string{ + "Content-Type": "text/plain; charset=utf-8", + }, }, "base64request": { input: base64EncodedBodyRequest, @@ -117,6 +140,31 @@ func TestWrap(t *testing.T) { }, expectStatus: http.StatusOK, expectBody: "", + expectHeaders: map[string]string{ + "Content-Type": "text/plain; charset=utf-8", + }, + }, + "writes html": { + input: helloRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("")) + }, + expectBody: "", + expectStatus: http.StatusOK, + expectHeaders: map[string]string{ + "Content-Type": "text/html; charset=utf-8", + }, + }, + "writes zeros": { + input: helloRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte{0, 0, 0, 0, 0}) + }, + expectBody: "\x00\x00\x00\x00\x00", + expectStatus: http.StatusOK, + expectHeaders: map[string]string{ + "Content-Type": "application/octet-stream", + }, }, } { t.Run(name, func(t *testing.T) { From 3a7932ba53bb4ccc09380378d78ef9b6ccc1a880 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Tue, 16 May 2023 16:46:02 -0700 Subject: [PATCH 02/13] Make content type detection optional --- lambdaurl/http_handler.go | 95 ++++++++++++++++++++++++---------- lambdaurl/http_handler_test.go | 60 ++++++++++----------- 2 files changed, 95 insertions(+), 60 deletions(-) diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index 5512aec1..e44c9f52 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -18,36 +18,64 @@ import ( "github.com/aws/aws-lambda-go/lambda" ) +type options struct { + detectContentType bool + bufferHeader bool +} + +type Option func(*options) + +// WithDetectContentType sets the behavior of content type detection when the Content-Type header is not already provided. +// When true, the first Write call will pass the intial bytes to http.DetectContentType. +// When false, and if no Content-Type is provided, no Content-Type will be sent back to Lambda, +// and the Lambda Function URL will fallback to it's default. +// +// Note: The http.ResponseWriter passed to the handler is unbuffered. +// This may result in different Content-Type headers in the Function URL response when compared to http.ListenAndServe. +func WithDetectContentType(detectContentType bool) func(*options) { + return func(options *options) { + options.detectContentType = detectContentType + } +} + type httpResponseWriter struct { + options options + header http.Header + writer io.Writer + once sync.Once + ready chan<- header +} + +type header struct { + code int header http.Header - writer io.Writer - once sync.Once - status chan<- int } func (w *httpResponseWriter) Header() http.Header { + if w.header == nil { + w.header = http.Header{} + } return w.header } func (w *httpResponseWriter) Write(p []byte) (int, error) { - w.once.Do(func() { - w.detectContentType(p) - w.status <- http.StatusOK - }) + w.writeHeader(http.StatusOK, p) return w.writer.Write(p) } func (w *httpResponseWriter) WriteHeader(statusCode int) { - w.once.Do(func() { - w.detectContentType(nil) - w.status <- statusCode - }) + w.writeHeader(statusCode, nil) } -func (w *httpResponseWriter) detectContentType(p []byte) { - if w.header.Get("Content-Type") == "" { - w.header.Set("Content-Type", http.DetectContentType(p)) - } +func (w *httpResponseWriter) writeHeader(statusCode int, initialPayload []byte) { + w.once.Do(func() { + if w.options.detectContentType { + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", http.DetectContentType(initialPayload)) + } + } + w.ready <- header{code: statusCode, header: w.header} + }) } type requestContextKey struct{} @@ -62,11 +90,21 @@ func RequestFromContext(ctx context.Context) (*events.LambdaFunctionURLRequest, // // Only Lambda Function URLs configured with `InvokeMode: RESPONSE_STREAM` are supported with the returned handler. // The response body of the handler will conform to the content-type `application/vnd.awslambda.http-integration-response`. -// -// Note: The http.ResponseWriter passed to the handler is unbuffered. -// This may result in different Content-Type and Content-Length headers in the Function URL response when compared to http.ListenAndServe. func Wrap(handler http.Handler) func(context.Context, *events.LambdaFunctionURLRequest) (*events.LambdaFunctionURLStreamingResponse, error) { + return wrap(handler) +} + +// WrapWithOptions 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 to the content-type `application/vnd.awslambda.http-integration-response`. +func WrapWithOptions(handler http.Handler, options ...Option) func(context.Context, *events.LambdaFunctionURLRequest) (*events.LambdaFunctionURLStreamingResponse, error) { + return wrap(handler, options...) +} + +func wrap(handler http.Handler, options ...Option) 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) @@ -83,23 +121,26 @@ func Wrap(handler http.Handler) func(context.Context, *events.LambdaFunctionURLR 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{} + ready := make(chan header) // Signals when it's OK to start returning the response body to Lambda r, w := io.Pipe() go func() { - defer close(status) + defer close(ready) defer w.Close() // TODO: recover and CloseWithError the any panic value once the runtime API client supports plumbing fatal errors through the reader - responseWriter := &httpResponseWriter{writer: w, header: header, status: status} - defer responseWriter.Write(nil) + responseWriter := &httpResponseWriter{writer: w, ready: ready} + defer responseWriter.Write(nil) // force default status, headers, content type detection, if none occured during the execution of the handler + for _, f := range options { + f(&responseWriter.options) + } handler.ServeHTTP(responseWriter, httpRequest) }() + header := <-ready response := &events.LambdaFunctionURLStreamingResponse{ Body: r, - StatusCode: <-status, + StatusCode: header.code, } - if len(header) > 0 { - response.Headers = make(map[string]string, len(header)) - for k, v := range header { + if len(header.header) > 0 { + response.Headers = make(map[string]string, len(header.header)) + for k, v := range header.header { if k == "Set-Cookie" { response.Cookies = v } else { diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index b6c4cbd0..0faeb68e 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -35,12 +35,13 @@ 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 + input []byte + handler http.HandlerFunc + handlerOptions []Option + expectStatus int + expectBody string + expectHeaders map[string]string + expectCookies []string }{ "hello": { input: helloRequest, @@ -58,11 +59,8 @@ func TestWrap(t *testing.T) { 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", - "Content-Type": "text/plain; charset=utf-8", - }, + expectStatus: http.StatusTeapot, + expectHeaders: map[string]string{"Hello": "world1,world2"}, expectCookies: []string{ "yummy=cookie", "yummy=cake", @@ -83,9 +81,6 @@ func TestWrap(t *testing.T) { }) mux.ServeHTTP(w, r) }, - expectHeaders: map[string]string{ - "Content-Type": "text/plain; charset=utf-8", - }, expectStatus: 200, expectBody: "Hello World!", }, @@ -98,9 +93,6 @@ func TestWrap(t *testing.T) { }, expectStatus: http.StatusOK, expectBody: "\"GET\"\n\"https://lambda-url-id.lambda-url.us-west-2.on.aws/\"\n", - expectHeaders: map[string]string{ - "Content-Type": "text/plain; charset=utf-8", - }, }, "get-explicit-trailing-slash": { input: domainOnlyWithSlashGetRequest, @@ -111,17 +103,11 @@ func TestWrap(t *testing.T) { }, expectStatus: http.StatusOK, expectBody: "\"GET\"\n\"https://lambda-url-id.lambda-url.us-west-2.on.aws/\"\n", - expectHeaders: map[string]string{ - "Content-Type": "text/plain; charset=utf-8", - }, }, "empty handler": { input: helloRequest, handler: func(w http.ResponseWriter, r *http.Request) {}, expectStatus: http.StatusOK, - expectHeaders: map[string]string{ - "Content-Type": "text/plain; charset=utf-8", - }, }, "write status code only": { input: helloRequest, @@ -129,9 +115,6 @@ func TestWrap(t *testing.T) { w.WriteHeader(http.StatusAccepted) }, expectStatus: http.StatusAccepted, - expectHeaders: map[string]string{ - "Content-Type": "text/plain; charset=utf-8", - }, }, "base64request": { input: base64EncodedBodyRequest, @@ -140,35 +123,46 @@ func TestWrap(t *testing.T) { }, expectStatus: http.StatusOK, expectBody: "", + }, + "detect content type: write status code only": { + input: helloRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + handlerOptions: []Option{WithDetectContentType(true)}, + expectStatus: http.StatusAccepted, expectHeaders: map[string]string{ "Content-Type": "text/plain; charset=utf-8", }, }, - "writes html": { + + "detect content type: writes html": { input: helloRequest, handler: func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("")) }, - expectBody: "", - expectStatus: http.StatusOK, + handlerOptions: []Option{WithDetectContentType(true)}, + expectBody: "", + expectStatus: http.StatusOK, expectHeaders: map[string]string{ "Content-Type": "text/html; charset=utf-8", }, }, - "writes zeros": { + "detect content type: writes zeros": { input: helloRequest, handler: func(w http.ResponseWriter, r *http.Request) { w.Write([]byte{0, 0, 0, 0, 0}) }, - expectBody: "\x00\x00\x00\x00\x00", - expectStatus: http.StatusOK, + handlerOptions: []Option{WithDetectContentType(true)}, + expectBody: "\x00\x00\x00\x00\x00", + expectStatus: http.StatusOK, expectHeaders: map[string]string{ "Content-Type": "application/octet-stream", }, }, } { t.Run(name, func(t *testing.T) { - handler := Wrap(params.handler) + handler := WrapWithOptions(params.handler, params.handlerOptions...) var req events.LambdaFunctionURLRequest require.NoError(t, json.Unmarshal(params.input, &req)) res, err := handler(context.Background(), &req) From ce8f7f12bf86d7b8603ccf0a5d4f7c461c4bd7ca Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Tue, 16 May 2023 20:43:16 -0700 Subject: [PATCH 03/13] Plumb the lambdaurl option as a context value --- lambda/handler.go | 22 +++++++++++++ lambdaurl/http_handler.go | 57 ++++++++++++++-------------------- lambdaurl/http_handler_test.go | 35 +++++++++++---------- 3 files changed, 64 insertions(+), 50 deletions(-) diff --git a/lambda/handler.go b/lambda/handler.go index e4cfaf7a..f43fadc6 100644 --- a/lambda/handler.go +++ b/lambda/handler.go @@ -23,6 +23,7 @@ type Handler interface { type handlerOptions struct { handlerFunc baseContext context.Context + contextValues map[interface{}]interface{} jsonResponseEscapeHTML bool jsonResponseIndentPrefix string jsonResponseIndentValue string @@ -48,6 +49,23 @@ func WithContext(ctx context.Context) Option { }) } +// WithContextValue adds a value to the handler context. +// If a base context was set using WithContext, that base is used at the parent. +// +// Usage: +// +// lambda.StartWithOptions( +// func (ctx context.Context) (string, error) { +// return ctx.Value("foo"), nil +// }, +// lambda.WithContextValue("foo", "bar") +// ) +func WithContextValue(key interface{}, value interface{}) Option { + return Option(func(h *handlerOptions) { + h.contextValues[key] = value + }) +} + // WithSetEscapeHTML sets the SetEscapeHTML argument on the underlying json encoder // // Usage: @@ -177,6 +195,7 @@ func newHandler(handlerFunc interface{}, options ...Option) *handlerOptions { } h := &handlerOptions{ baseContext: context.Background(), + contextValues: map[interface{}]interface{}{}, jsonResponseEscapeHTML: false, jsonResponseIndentPrefix: "", jsonResponseIndentValue: "", @@ -184,6 +203,9 @@ func newHandler(handlerFunc interface{}, options ...Option) *handlerOptions { for _, option := range options { option(h) } + for k, v := range h.contextValues { + h.baseContext = context.WithValue(h.baseContext, k, v) + } if h.enableSIGTERM { enableSIGTERM(h.sigtermCallbacks) } diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index e44c9f52..1ab1936d 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -18,12 +18,7 @@ import ( "github.com/aws/aws-lambda-go/lambda" ) -type options struct { - detectContentType bool - bufferHeader bool -} - -type Option func(*options) +type detectContentTypeContextKey struct{} // WithDetectContentType sets the behavior of content type detection when the Content-Type header is not already provided. // When true, the first Write call will pass the intial bytes to http.DetectContentType. @@ -32,18 +27,25 @@ type Option func(*options) // // Note: The http.ResponseWriter passed to the handler is unbuffered. // This may result in different Content-Type headers in the Function URL response when compared to http.ListenAndServe. -func WithDetectContentType(detectContentType bool) func(*options) { - return func(options *options) { - options.detectContentType = detectContentType - } +// +// Usage: +// +// lambdaurl.Start( +// http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { +// w.Write("") +// }), +// lambdaurl.WithDetectContentType(true) +// ) +func WithDetectContentType(detectContentType bool) lambda.Option { + return lambda.WithContextValue(detectContentTypeContextKey{}, detectContentType) } type httpResponseWriter struct { - options options - header http.Header - writer io.Writer - once sync.Once - ready chan<- header + detectContentType bool + header http.Header + writer io.Writer + once sync.Once + ready chan<- header } type header struct { @@ -69,7 +71,7 @@ func (w *httpResponseWriter) WriteHeader(statusCode int) { func (w *httpResponseWriter) writeHeader(statusCode int, initialPayload []byte) { w.once.Do(func() { - if w.options.detectContentType { + if w.detectContentType { if w.Header().Get("Content-Type") == "" { w.Header().Set("Content-Type", http.DetectContentType(initialPayload)) } @@ -91,18 +93,6 @@ func RequestFromContext(ctx context.Context) (*events.LambdaFunctionURLRequest, // Only Lambda Function URLs configured with `InvokeMode: RESPONSE_STREAM` are supported with the returned handler. // 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 wrap(handler) -} - -// WrapWithOptions 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 to the content-type `application/vnd.awslambda.http-integration-response`. -func WrapWithOptions(handler http.Handler, options ...Option) func(context.Context, *events.LambdaFunctionURLRequest) (*events.LambdaFunctionURLStreamingResponse, error) { - return wrap(handler, options...) -} - -func wrap(handler http.Handler, options ...Option) 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) @@ -121,16 +111,17 @@ func wrap(handler http.Handler, options ...Option) func(context.Context, *events for k, v := range request.Headers { httpRequest.Header.Add(k, v) } + ready := make(chan header) // Signals when it's OK to start returning the response body to Lambda r, w := io.Pipe() + responseWriter := &httpResponseWriter{writer: w, ready: ready} + if detectContentType, ok := ctx.Value(detectContentTypeContextKey{}).(bool); ok { + responseWriter.detectContentType = detectContentType + } go func() { defer close(ready) - defer w.Close() // TODO: recover and CloseWithError the any panic value once the runtime API client supports plumbing fatal errors through the reader - responseWriter := &httpResponseWriter{writer: w, ready: ready} + defer w.Close() // TODO: recover and CloseWithError the any panic value once the runtime API client supports plumbing fatal errors through the reader defer responseWriter.Write(nil) // force default status, headers, content type detection, if none occured during the execution of the handler - for _, f := range options { - f(&responseWriter.options) - } handler.ServeHTTP(responseWriter, httpRequest) }() header := <-ready diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index 0faeb68e..980e8e6a 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -35,13 +35,13 @@ var base64EncodedBodyRequest []byte func TestWrap(t *testing.T) { for name, params := range map[string]struct { - input []byte - handler http.HandlerFunc - handlerOptions []Option - expectStatus int - expectBody string - expectHeaders map[string]string - expectCookies []string + input []byte + handler http.HandlerFunc + detectContentType bool + expectStatus int + expectBody string + expectHeaders map[string]string + expectCookies []string }{ "hello": { input: helloRequest, @@ -129,8 +129,8 @@ func TestWrap(t *testing.T) { handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) }, - handlerOptions: []Option{WithDetectContentType(true)}, - expectStatus: http.StatusAccepted, + detectContentType: true, + expectStatus: http.StatusAccepted, expectHeaders: map[string]string{ "Content-Type": "text/plain; charset=utf-8", }, @@ -141,9 +141,9 @@ func TestWrap(t *testing.T) { handler: func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("")) }, - handlerOptions: []Option{WithDetectContentType(true)}, - expectBody: "", - expectStatus: http.StatusOK, + detectContentType: true, + expectBody: "", + expectStatus: http.StatusOK, expectHeaders: map[string]string{ "Content-Type": "text/html; charset=utf-8", }, @@ -153,19 +153,20 @@ func TestWrap(t *testing.T) { handler: func(w http.ResponseWriter, r *http.Request) { w.Write([]byte{0, 0, 0, 0, 0}) }, - handlerOptions: []Option{WithDetectContentType(true)}, - expectBody: "\x00\x00\x00\x00\x00", - expectStatus: http.StatusOK, + detectContentType: true, + expectBody: "\x00\x00\x00\x00\x00", + expectStatus: http.StatusOK, expectHeaders: map[string]string{ "Content-Type": "application/octet-stream", }, }, } { t.Run(name, func(t *testing.T) { - handler := WrapWithOptions(params.handler, params.handlerOptions...) + handler := Wrap(params.handler) var req events.LambdaFunctionURLRequest require.NoError(t, json.Unmarshal(params.input, &req)) - res, err := handler(context.Background(), &req) + ctx := context.WithValue(context.Background(), detectContentTypeContextKey{}, params.detectContentType) + res, err := handler(ctx, &req) require.NoError(t, err) resultBodyBytes, err := ioutil.ReadAll(res) require.NoError(t, err) From 122d5237b61c41fc2781dcdc83d9828d688641e1 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Tue, 16 May 2023 21:17:43 -0700 Subject: [PATCH 04/13] When detecting a content type, default to application/octet-stream --- lambdaurl/http_handler.go | 12 +++++++++++- lambdaurl/http_handler_test.go | 13 +++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index 1ab1936d..2f4a4a31 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -73,13 +73,23 @@ func (w *httpResponseWriter) writeHeader(statusCode int, initialPayload []byte) w.once.Do(func() { if w.detectContentType { if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", http.DetectContentType(initialPayload)) + w.Header().Set("Content-Type", detectContentType(initialPayload)) } } w.ready <- header{code: statusCode, header: w.header} }) } +func detectContentType(p []byte) string { + // http.DetectContentType returns "text/plain; charset=utf-8" for nil and zero-length byte slices. + // This is a weird behavior, since otherwise it defaults to "application/octet-stream"! So we'll do that. + // This differs from http.ListenAndServe, which set no Content-Type when the initial Flush body is empty. + if len(p) == 0 { + return "application/octet-stream" + } + return http.DetectContentType(p) +} + type requestContextKey struct{} // RequestFromContext returns the *events.LambdaFunctionURLRequest from a context. diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index 980e8e6a..f6d9ffb9 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -132,10 +132,19 @@ func TestWrap(t *testing.T) { detectContentType: true, expectStatus: http.StatusAccepted, expectHeaders: map[string]string{ - "Content-Type": "text/plain; charset=utf-8", + "Content-Type": "application/octet-stream", + }, + }, + "detect content type: empty handler": { + input: helloRequest, + handler: func(w http.ResponseWriter, r *http.Request) { + }, + detectContentType: true, + expectStatus: http.StatusOK, + expectHeaders: map[string]string{ + "Content-Type": "application/octet-stream", }, }, - "detect content type: writes html": { input: helloRequest, handler: func(w http.ResponseWriter, r *http.Request) { From c791e71ae0ea3261d9cab614ac4e8c2da40df110 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 10:49:08 -0800 Subject: [PATCH 05/13] nolint:errcheck --- lambdaurl/http_handler.go | 3 ++- lambdaurl/http_handler_test.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lambdaurl/http_handler.go b/lambdaurl/http_handler.go index 2f4a4a31..79a63e18 100644 --- a/lambdaurl/http_handler.go +++ b/lambdaurl/http_handler.go @@ -130,7 +130,8 @@ func Wrap(handler http.Handler) func(context.Context, *events.LambdaFunctionURLR } go func() { defer close(ready) - defer w.Close() // TODO: recover and CloseWithError the any panic value once the runtime API client supports plumbing fatal errors through the reader + defer w.Close() // TODO: recover and CloseWithError the any panic value once the runtime API client supports plumbing fatal errors through the reader + //nolint:errcheck defer responseWriter.Write(nil) // force default status, headers, content type detection, if none occured during the execution of the handler handler.ServeHTTP(responseWriter, httpRequest) }() diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index f6d9ffb9..b51a0640 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -148,7 +148,7 @@ func TestWrap(t *testing.T) { "detect content type: writes html": { input: helloRequest, handler: func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("")) + _, _ = w.Write([]byte("")) }, detectContentType: true, expectBody: "", @@ -160,7 +160,7 @@ func TestWrap(t *testing.T) { "detect content type: writes zeros": { input: helloRequest, handler: func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte{0, 0, 0, 0, 0}) + _, _ = w.Write([]byte{0, 0, 0, 0, 0}) }, detectContentType: true, expectBody: "\x00\x00\x00\x00\x00", From 1aa2beede4bed2c6c92f8bb7747b06cac81e491f Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 11:28:27 -0800 Subject: [PATCH 06/13] Add some coverage of Start and WithDetectContentType plumbing via the emulator --- lambdaurl/http_handler_test.go | 55 +++++++++++++++++++++++++++++++++ lambdaurl/testdata/lambdaurl.go | 25 +++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 lambdaurl/testdata/lambdaurl.go diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index b51a0640..a72d599e 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -13,6 +13,10 @@ import ( "io/ioutil" "log" "net/http" + "os" + "os/exec" + "path" + "strings" "testing" "time" @@ -207,3 +211,54 @@ func TestRequestContext(t *testing.T) { _, err := handler(context.Background(), req) require.NoError(t, err) } + +func TestStartViaEmulator(t *testing.T) { + rieInvokeAPI := "http://localhost:8080/2015-03-31/functions/function/invocations" + if _, err := exec.LookPath("aws-lambda-rie"); err != nil { + t.Skipf("%v - install from https://github.com/aws/aws-lambda-runtime-interface-emulator/", err) + } + + // compile our handler, it'll always run to timeout ensuring the SIGTERM is triggered by aws-lambda-rie + testDir := t.TempDir() + handlerBuild := exec.Command("go", "build", "-o", path.Join(testDir, "lambdaurl.handler"), "./testdata/lambdaurl.go") + handlerBuild.Stderr = os.Stderr + handlerBuild.Stdout = os.Stderr + require.NoError(t, handlerBuild.Run()) + + // run the runtime interface emulator, capture the logs for assertion + cmd := exec.Command("aws-lambda-rie", "lambdaurl.handler") + cmd.Env = []string{ + "PATH=" + testDir, + "AWS_LAMBDA_FUNCTION_TIMEOUT=2", + } + cmd.Stderr = os.Stderr + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + var logs string + done := make(chan interface{}) // closed on completion of log flush + go func() { + logBytes, err := ioutil.ReadAll(stdout) + require.NoError(t, err) + logs = string(logBytes) + close(done) + }() + require.NoError(t, cmd.Start()) + t.Cleanup(func() { _ = cmd.Process.Kill() }) + + // give a moment for the port to bind + time.Sleep(500 * time.Millisecond) + + client := &http.Client{Timeout: 5 * time.Second} // http client timeout to prevent case from hanging on aws-lambda-rie + resp, err := client.Post(rieInvokeAPI, "application/json", strings.NewReader("{}")) + require.NoError(t, err) + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err) + + expected := "{\"statusCode\":200,\"headers\":{\"Content-Type\":\"text/html; charset=utf-8\"}}\x00\x00\x00\x00\x00\x00\x00\x00\n\n\nHello World!\n\n\n" + assert.Equal(t, expected, string(body)) + + require.NoError(t, cmd.Process.Kill()) // now ensure the logs are drained + <-done + t.Logf("stdout:\n%s", logs) +} diff --git a/lambdaurl/testdata/lambdaurl.go b/lambdaurl/testdata/lambdaurl.go new file mode 100644 index 00000000..ee12462f --- /dev/null +++ b/lambdaurl/testdata/lambdaurl.go @@ -0,0 +1,25 @@ +package main + +import ( + "io" + "net/http" + "strings" + + "github.com/aws/aws-lambda-go/lambdaurl" +) + +const content = ` + + +Hello World! + + +` + +func main() { + lambdaurl.Start(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(w, strings.NewReader(content)) + }), + lambdaurl.WithDetectContentType(true), + ) +} From 15bba454d5be60516d791a8068f8ca3b5bc3e2c7 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 11:59:39 -0800 Subject: [PATCH 07/13] try to mitigate against emulator test flakyness --- lambdaurl/http_handler_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index a72d599e..ffce4d61 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -12,10 +12,12 @@ import ( "io" "io/ioutil" "log" + "math/rand" "net/http" "os" "os/exec" "path" + "strconv" "strings" "testing" "time" @@ -213,7 +215,8 @@ func TestRequestContext(t *testing.T) { } func TestStartViaEmulator(t *testing.T) { - rieInvokeAPI := "http://localhost:8080/2015-03-31/functions/function/invocations" + addr := "localhost:" + strconv.Itoa(8000+rand.Intn(999)) + rieInvokeAPI := "http://" + addr + "/2015-03-31/functions/function/invocations" if _, err := exec.LookPath("aws-lambda-rie"); err != nil { t.Skipf("%v - install from https://github.com/aws/aws-lambda-runtime-interface-emulator/", err) } @@ -226,7 +229,7 @@ func TestStartViaEmulator(t *testing.T) { require.NoError(t, handlerBuild.Run()) // run the runtime interface emulator, capture the logs for assertion - cmd := exec.Command("aws-lambda-rie", "lambdaurl.handler") + cmd := exec.Command("aws-lambda-rie", "--runtime-interface-emulator-address", addr, "lambdaurl.handler") cmd.Env = []string{ "PATH=" + testDir, "AWS_LAMBDA_FUNCTION_TIMEOUT=2", From 6f690ed4d773549f8b277970b51e1a6900b5ee2e Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 12:02:33 -0800 Subject: [PATCH 08/13] fail-fast: false --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 784d704e..9279e76f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,6 +8,7 @@ jobs: name: run tests runs-on: ubuntu-latest strategy: + fail-fast: false matrix: go: - "1.21" From 9a29a35a34adfe496d2cd7f0b06f462298b92945 Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 13:09:56 -0800 Subject: [PATCH 09/13] more --- lambda/sigterm_test.go | 11 +++++++---- lambdaurl/http_handler_test.go | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lambda/sigterm_test.go b/lambda/sigterm_test.go index 886a6d72..1cc9abfb 100644 --- a/lambda/sigterm_test.go +++ b/lambda/sigterm_test.go @@ -5,10 +5,12 @@ package lambda import ( "io/ioutil" //nolint: staticcheck + "math/rand" "net/http" "os" "os/exec" "path" + "strconv" "strings" "testing" "time" @@ -17,9 +19,7 @@ import ( "github.com/stretchr/testify/require" ) -const ( - rieInvokeAPI = "http://localhost:8080/2015-03-31/functions/function/invocations" -) +const () func TestEnableSigterm(t *testing.T) { if _, err := exec.LookPath("aws-lambda-rie"); err != nil { @@ -53,8 +53,11 @@ func TestEnableSigterm(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { + addr1 := "localhost:" + strconv.Itoa(8000+rand.Intn(999)) + addr2 := "localhost:" + strconv.Itoa(9000+rand.Intn(999)) + rieInvokeAPI := "http://" + addr1 + "/2015-03-31/functions/function/invocations" // run the runtime interface emulator, capture the logs for assertion - cmd := exec.Command("aws-lambda-rie", "sigterm.handler") + cmd := exec.Command("aws-lambda-rie", "--runtime-interface-emulator-address", addr1, "--runtime-api-address", addr2, "sigterm.handler") cmd.Env = append([]string{ "PATH=" + testDir, "AWS_LAMBDA_FUNCTION_TIMEOUT=2", diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index ffce4d61..4e752b27 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -215,8 +215,9 @@ func TestRequestContext(t *testing.T) { } func TestStartViaEmulator(t *testing.T) { - addr := "localhost:" + strconv.Itoa(8000+rand.Intn(999)) - rieInvokeAPI := "http://" + addr + "/2015-03-31/functions/function/invocations" + addr1 := "localhost:" + strconv.Itoa(8000+rand.Intn(999)) + addr2 := "localhost:" + strconv.Itoa(9000+rand.Intn(999)) + rieInvokeAPI := "http://" + addr1 + "/2015-03-31/functions/function/invocations" if _, err := exec.LookPath("aws-lambda-rie"); err != nil { t.Skipf("%v - install from https://github.com/aws/aws-lambda-runtime-interface-emulator/", err) } @@ -229,7 +230,7 @@ func TestStartViaEmulator(t *testing.T) { require.NoError(t, handlerBuild.Run()) // run the runtime interface emulator, capture the logs for assertion - cmd := exec.Command("aws-lambda-rie", "--runtime-interface-emulator-address", addr, "lambdaurl.handler") + cmd := exec.Command("aws-lambda-rie", "--runtime-interface-emulator-address", addr1, "--runtime-api-address", addr2, "lambdaurl.handler") cmd.Env = []string{ "PATH=" + testDir, "AWS_LAMBDA_FUNCTION_TIMEOUT=2", From 6fee4e6200391c06fe05b3f7f43e5d91527d6a6b Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 13:11:33 -0800 Subject: [PATCH 10/13] gofmt --- lambda/sigterm_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/lambda/sigterm_test.go b/lambda/sigterm_test.go index 1cc9abfb..07fb5598 100644 --- a/lambda/sigterm_test.go +++ b/lambda/sigterm_test.go @@ -19,8 +19,6 @@ import ( "github.com/stretchr/testify/require" ) -const () - func TestEnableSigterm(t *testing.T) { if _, err := exec.LookPath("aws-lambda-rie"); err != nil { t.Skipf("%v - install from https://github.com/aws/aws-lambda-runtime-interface-emulator/", err) From 525974e8d5d2193d2916a1d50fc162aa174cc81e Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 13:21:58 -0800 Subject: [PATCH 11/13] ugh --- lambda/sigterm_test.go | 7 ++++--- lambdaurl/http_handler_test.go | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lambda/sigterm_test.go b/lambda/sigterm_test.go index 07fb5598..278d5926 100644 --- a/lambda/sigterm_test.go +++ b/lambda/sigterm_test.go @@ -5,7 +5,6 @@ package lambda import ( "io/ioutil" //nolint: staticcheck - "math/rand" "net/http" "os" "os/exec" @@ -32,6 +31,7 @@ func TestEnableSigterm(t *testing.T) { handlerBuild.Stdout = os.Stderr require.NoError(t, handlerBuild.Run()) + portI := 0 for name, opts := range map[string]struct { envVars []string assertLogs func(t *testing.T, logs string) @@ -51,8 +51,9 @@ func TestEnableSigterm(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { - addr1 := "localhost:" + strconv.Itoa(8000+rand.Intn(999)) - addr2 := "localhost:" + strconv.Itoa(9000+rand.Intn(999)) + portI += 1 + addr1 := "localhost:" + strconv.Itoa(8000+portI) + addr2 := "localhost:" + strconv.Itoa(9000+portI) rieInvokeAPI := "http://" + addr1 + "/2015-03-31/functions/function/invocations" // run the runtime interface emulator, capture the logs for assertion cmd := exec.Command("aws-lambda-rie", "--runtime-interface-emulator-address", addr1, "--runtime-api-address", addr2, "sigterm.handler") diff --git a/lambdaurl/http_handler_test.go b/lambdaurl/http_handler_test.go index 4e752b27..419cbd7c 100644 --- a/lambdaurl/http_handler_test.go +++ b/lambdaurl/http_handler_test.go @@ -12,7 +12,6 @@ import ( "io" "io/ioutil" "log" - "math/rand" "net/http" "os" "os/exec" @@ -215,8 +214,8 @@ func TestRequestContext(t *testing.T) { } func TestStartViaEmulator(t *testing.T) { - addr1 := "localhost:" + strconv.Itoa(8000+rand.Intn(999)) - addr2 := "localhost:" + strconv.Itoa(9000+rand.Intn(999)) + addr1 := "localhost:" + strconv.Itoa(6001) + addr2 := "localhost:" + strconv.Itoa(7001) rieInvokeAPI := "http://" + addr1 + "/2015-03-31/functions/function/invocations" if _, err := exec.LookPath("aws-lambda-rie"); err != nil { t.Skipf("%v - install from https://github.com/aws/aws-lambda-runtime-interface-emulator/", err) From 2cd6dd353315e9bed60e9c969290f40f2c62eb8a Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 14:21:54 -0800 Subject: [PATCH 12/13] gofmt --- lambda/handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambda/handler.go b/lambda/handler.go index e05c6096..29196330 100644 --- a/lambda/handler.go +++ b/lambda/handler.go @@ -23,7 +23,7 @@ type Handler interface { type handlerOptions struct { handlerFunc baseContext context.Context - contextValues map[interface{}]interface{} + contextValues map[interface{}]interface{} jsonRequestUseNumber bool jsonRequestDisallowUnknownFields bool jsonResponseEscapeHTML bool From 0ea7933d105b201132afd28a04ee5a3893fcda9a Mon Sep 17 00:00:00 2001 From: Bryan Moffatt Date: Thu, 30 Nov 2023 15:56:00 -0800 Subject: [PATCH 13/13] Update handler.go fix typo --- lambda/handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambda/handler.go b/lambda/handler.go index 29196330..c455656d 100644 --- a/lambda/handler.go +++ b/lambda/handler.go @@ -52,7 +52,7 @@ func WithContext(ctx context.Context) Option { } // WithContextValue adds a value to the handler context. -// If a base context was set using WithContext, that base is used at the parent. +// If a base context was set using WithContext, that base is used as the parent. // // Usage: //