diff --git a/internal/server/lambda.go b/internal/server/lambda.go index 98c22c807..c5158afcc 100644 --- a/internal/server/lambda.go +++ b/internal/server/lambda.go @@ -148,7 +148,11 @@ func (app *Application) handleLambdaHTTPEvent(ctx context.Context, rawEvent json return app.serveLambdaStatic(reqPath) } - return app.API.HandleRequest(ctx, &request) + resp, err := app.API.HandleRequest(ctx, &request) + if err == nil { + compressLambdaResponse(&request, resp) + } + return resp, err } // lambdaSecurityHeaders returns the standard security headers for Lambda responses. diff --git a/internal/server/lambda_compress.go b/internal/server/lambda_compress.go new file mode 100644 index 000000000..27d1b0b2a --- /dev/null +++ b/internal/server/lambda_compress.go @@ -0,0 +1,67 @@ +package server + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "log" + "strings" + + "github.com/aws/aws-lambda-go/events" +) + +// lambdaCompressMinBytes is the smallest JSON body worth compressing. A var so +// the wiring test can force compression on a tiny response. +var lambdaCompressMinBytes = 64 * 1024 + +// compressLambdaResponse gzips a large JSON API response for clients that +// accept it. Lambda rejects responses over 6 MB, and JSON lists such as +// GET /api/recommendations grow with the tenant (#723); gzip leaves several +// times that headroom even after the base64 Lambda requires for a binary +// body. Any condition that is not met leaves the response untouched. +func compressLambdaResponse(req *events.LambdaFunctionURLRequest, resp *events.LambdaFunctionURLResponse) { + if resp == nil || resp.IsBase64Encoded || len(resp.Body) < lambdaCompressMinBytes { + return + } + if !acceptsGzip(req.Headers) || !isJSONResponse(resp.Headers) { + return + } + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + if _, err := zw.Write([]byte(resp.Body)); err != nil { + log.Printf("lambda response gzip failed, sending uncompressed: %v", err) + return + } + if err := zw.Close(); err != nil { + log.Printf("lambda response gzip failed, sending uncompressed: %v", err) + return + } + resp.Body = base64.StdEncoding.EncodeToString(buf.Bytes()) + resp.IsBase64Encoded = true + resp.Headers["Content-Encoding"] = "gzip" + resp.Headers["Vary"] = "Accept-Encoding" +} + +func acceptsGzip(headers map[string]string) bool { + for k, v := range headers { + if strings.EqualFold(k, "accept-encoding") { + return strings.Contains(strings.ToLower(v), "gzip") + } + } + return false +} + +// isJSONResponse is false when the response already carries a +// Content-Encoding, so a body is never encoded twice. +func isJSONResponse(headers map[string]string) bool { + json := false + for k, v := range headers { + switch strings.ToLower(k) { + case "content-encoding": + return false + case "content-type": + json = strings.HasPrefix(strings.ToLower(v), "application/json") + } + } + return json +} diff --git a/internal/server/lambda_compress_test.go b/internal/server/lambda_compress_test.go new file mode 100644 index 000000000..5ac2adaa0 --- /dev/null +++ b/internal/server/lambda_compress_test.go @@ -0,0 +1,102 @@ +package server + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/api" + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func largeJSONResponse() *events.LambdaFunctionURLResponse { + return &events.LambdaFunctionURLResponse{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: `{"rows":"` + strings.Repeat("recommendation-", 8000) + `"}`, + } +} + +func gzipAccepting() *events.LambdaFunctionURLRequest { + return &events.LambdaFunctionURLRequest{Headers: map[string]string{"accept-encoding": "gzip, deflate, br"}} +} + +func TestCompressLambdaResponse_CompressesLargeJSON(t *testing.T) { + resp := largeJSONResponse() + original := resp.Body + + compressLambdaResponse(gzipAccepting(), resp) + + assert.True(t, resp.IsBase64Encoded) + assert.Equal(t, "gzip", resp.Headers["Content-Encoding"]) + assert.Equal(t, "Accept-Encoding", resp.Headers["Vary"]) + raw, err := base64.StdEncoding.DecodeString(resp.Body) + require.NoError(t, err) + assert.Less(t, len(raw), len(original)/10, "repetitive JSON must shrink by far more than the base64 overhead") + zr, err := gzip.NewReader(bytes.NewReader(raw)) + require.NoError(t, err) + got, err := io.ReadAll(zr) + require.NoError(t, err) + assert.Equal(t, original, string(got)) +} + +func TestCompressLambdaResponse_LeavesResponseAloneWhen(t *testing.T) { + tests := []struct { + name string + req *events.LambdaFunctionURLRequest + mutate func(*events.LambdaFunctionURLResponse) + }{ + {"client does not accept gzip", &events.LambdaFunctionURLRequest{Headers: map[string]string{"accept-encoding": "br"}}, nil}, + {"no accept-encoding header", &events.LambdaFunctionURLRequest{}, nil}, + {"body below threshold", gzipAccepting(), func(r *events.LambdaFunctionURLResponse) { r.Body = `{"ok":true}` }}, + {"not JSON", gzipAccepting(), func(r *events.LambdaFunctionURLResponse) { r.Headers["Content-Type"] = "text/html" }}, + {"already encoded", gzipAccepting(), func(r *events.LambdaFunctionURLResponse) { r.Headers["Content-Encoding"] = "br" }}, + {"already base64", gzipAccepting(), func(r *events.LambdaFunctionURLResponse) { r.IsBase64Encoded = true }}, + {"no headers", gzipAccepting(), func(r *events.LambdaFunctionURLResponse) { r.Headers = nil }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := largeJSONResponse() + if tt.mutate != nil { + tt.mutate(resp) + } + body, b64, enc := resp.Body, resp.IsBase64Encoded, resp.Headers["Content-Encoding"] + + compressLambdaResponse(tt.req, resp) + + assert.Equal(t, body, resp.Body) + assert.Equal(t, b64, resp.IsBase64Encoded) + assert.Equal(t, enc, resp.Headers["Content-Encoding"]) + }) + } +} + +// The wiring test drives the real handleLambdaHTTPEvent: a small API JSON +// response is compressed only because the threshold is lowered for the test. +func TestHandleLambdaHTTPEvent_CompressesAPIJSONResponse(t *testing.T) { + old := lambdaCompressMinBytes + lambdaCompressMinBytes = 1 + t.Cleanup(func() { lambdaCompressMinBytes = old }) + app := &Application{API: api.NewHandler(api.HandlerConfig{})} + + rawEvent, err := json.Marshal(events.LambdaFunctionURLRequest{ + RawPath: "/api/recommendations", + Headers: map[string]string{"accept-encoding": "gzip"}, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: "GET", Path: "/api/recommendations"}, + }, + }) + require.NoError(t, err) + + resp, err := app.handleLambdaHTTPEvent(t.Context(), rawEvent) + + require.NoError(t, err) + assert.Equal(t, "gzip", resp.Headers["Content-Encoding"], "status %d body %q", resp.StatusCode, resp.Body) + assert.True(t, resp.IsBase64Encoded) +}