Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion internal/server/lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions internal/server/lambda_compress.go
Original file line number Diff line number Diff line change
@@ -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
}
102 changes: 102 additions & 0 deletions internal/server/lambda_compress_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading