Skip to content

Commit 6b81bb2

Browse files
authored
Optional gzip compression of responses (#157)
* Optional gzip compression of responses * Format with formatOnSave * Docs, rename env var, add 2 content-types * Remove `unsafe` from integration test * Add E2Es for compressed responses * Code review fixes
1 parent 73cb879 commit 6b81bb2

File tree

8 files changed

+312
-8
lines changed

8 files changed

+312
-8
lines changed

Cargo.lock

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ exclude = ["examples"]
2020
http = "0.2"
2121
hyper = { version = "0.14", features = ["client"] }
2222
lambda_http = "0.7.3"
23+
flate2 = "1.0.25"
2324
tokio = { version = "1.24", features = [
2425
"macros",
2526
"io-util",

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,15 @@ After passing readiness check, Lambda Web Adapter will start Lambda Runtime and
6969

7070
The readiness check port/path and traffic port can be configured using environment variables. These environment variables can be defined either within docker file or as Lambda function configuration.
7171

72-
| Environment Variable | Description | Default |
73-
|--------------------------|----------------------------------------------------------------------|---------|
74-
| PORT | traffic port | "8080" |
75-
| READINESS_CHECK_PORT | readiness check port, default to the traffic port | PORT |
76-
| READINESS_CHECK_PATH | readiness check path | "/" |
77-
| READINESS_CHECK_PROTOCOL | readiness check protocol: "http" or "tcp", default is "http" | "http" |
78-
| ASYNC_INIT | enable asynchronous initialization for long initialization functions | "false" |
79-
| REMOVE_BASE_PATH | (optional) the base path to be removed from request path | None |
72+
| Environment Variable | Description | Default |
73+
| -------------------------- | -------------------------------------------------------------------- | ------- |
74+
| PORT | traffic port | "8080" |
75+
| READINESS_CHECK_PORT | readiness check port, default to the traffic port | PORT |
76+
| READINESS_CHECK_PATH | readiness check path | "/" |
77+
| READINESS_CHECK_PROTOCOL | readiness check protocol: "http" or "tcp", default is "http" | "http" |
78+
| ASYNC_INIT | enable asynchronous initialization for long initialization functions | "false" |
79+
| REMOVE_BASE_PATH | (optional) the base path to be removed from request path | None |
80+
| AWS_LWA_ENABLE_COMPRESSION | (optional) enable gzip compression for response body | "false" |
8081

8182
**ASYNC_INIT** Lambda managed runtimes offer up to 10 seconds for function initialization. During this period of time, Lambda functions have burst of CPU to accelerate initialization, and it is free.
8283
If a lambda function couldn't complete the initialization within 10 seconds, Lambda will restart the function, and bill for the initialization.
@@ -85,6 +86,10 @@ When this feature is enabled, Lambda Web Adapter performs readiness check up to
8586
Lambda Web Adapter signals to Lambda service that the init is completed, and continues readiness check in the handler.
8687
This feature is disabled by default. Enable it by setting environment variable `ASYNC_INIT` to `true`.
8788

89+
**AWS_LWA_ENABLE_COMPRESSION** Lambda Web Adapter supports gzip compression for response body. This feature is disabled by default. Enable it by setting environment variable `AWS_LWA_ENABLE_COMPRESSION` to `true`.
90+
When enabled, Lambda Web Adapter will check the `Accept-Encoding` header in the request, and compress the response body if the header contains `gzip`, if the response body is not already compressed, and if the `Content-Type` starts with `text/` or is `application/javascript`, `application/json`, `application/json+ld`, `application/xml`, `application/xhtml+xml`, `application/x-javascript`, or `image/svg+xml`.
91+
Note that the `Content-Length` header will be set to the compressed size, not the original size.
92+
8893
**REMOVE_BASE_PATH** - The value of this environment variable tells the adapter whether the application is running under a base path.
8994
For example, you could have configured your API Gateway to have a /orders/{proxy+} and a /catalog/{proxy+} resource.
9095
Each resource is handled by a separate Lambda functions. For this reason, the application inside Lambda may not be aware of the fact that the /orders path exists.

src/lib.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use std::{
55
env,
66
future::Future,
7+
io::prelude::*,
78
pin::Pin,
89
sync::{
910
atomic::{AtomicBool, Ordering},
@@ -12,11 +13,14 @@ use std::{
1213
time::Duration,
1314
};
1415

16+
use flate2::write::GzEncoder;
17+
use flate2::Compression;
1518
use http::{
1619
header::{HeaderName, HeaderValue},
1720
Method, StatusCode, Uri,
1821
};
1922
use hyper::{
23+
body,
2024
body::HttpBody,
2125
client::{Client, HttpConnector},
2226
Body,
@@ -60,6 +64,7 @@ pub struct AdapterOptions {
6064
pub readiness_check_protocol: Protocol,
6165
pub base_path: Option<String>,
6266
pub async_init: bool,
67+
pub compression: bool,
6368
}
6469

6570
impl AdapterOptions {
@@ -79,6 +84,10 @@ impl AdapterOptions {
7984
.unwrap_or_else(|_| "false".to_string())
8085
.parse()
8186
.unwrap_or(false),
87+
compression: env::var("AWS_LWA_ENABLE_COMPRESSION")
88+
.unwrap_or_else(|_| "false".to_string())
89+
.parse()
90+
.unwrap_or(false),
8291
}
8392
}
8493
}
@@ -92,6 +101,7 @@ pub struct Adapter {
92101
ready_at_init: Arc<AtomicBool>,
93102
domain: Uri,
94103
base_path: Option<String>,
104+
compression: bool,
95105
}
96106

97107
impl Adapter {
@@ -120,6 +130,7 @@ impl Adapter {
120130
base_path: options.base_path.clone(),
121131
async_init: options.async_init,
122132
ready_at_init: Arc::new(AtomicBool::new(false)),
133+
compression: options.compression,
123134
}
124135
}
125136

@@ -206,6 +217,12 @@ impl Adapter {
206217
path = path.trim_start_matches(base_path);
207218
}
208219

220+
let accepts_gzip = parts
221+
.headers
222+
.get("accept-encoding")
223+
.map(|v| v.to_str().unwrap_or_default().contains("gzip"))
224+
.unwrap_or_default();
225+
209226
let mut req_headers = parts.headers;
210227

211228
// include request context in http header "x-amzn-request-context"
@@ -237,6 +254,49 @@ impl Adapter {
237254
tracing::debug!(status = %app_response.status(), body_size = app_response.body().size_hint().lower(),
238255
app_headers = ?app_response.headers().clone(), "responding to lambda event");
239256

257+
let response_compressed = app_response.headers().get("content-encoding").is_some();
258+
259+
let content_type = if let Some(content_type) = app_response.headers().get("content-type") {
260+
content_type.to_str().unwrap()
261+
} else {
262+
""
263+
};
264+
265+
let compressable_content_type = content_type.starts_with("text/")
266+
|| content_type.starts_with("application/json")
267+
|| content_type.starts_with("application/ld+json")
268+
|| content_type.starts_with("application/javascript")
269+
|| content_type.starts_with("image/svg+xml")
270+
|| content_type.starts_with("application/xhtml+xml")
271+
|| content_type.starts_with("application/x-javascript")
272+
|| content_type.starts_with("application/xml");
273+
274+
// Gzip the response if the client accepts it
275+
let app_response = if !self.compression {
276+
app_response
277+
} else if accepts_gzip && !response_compressed && compressable_content_type {
278+
let (parts, body) = app_response.into_parts();
279+
let mut builder = hyper::Response::builder().status(parts.status).version(parts.version);
280+
if let Some(headers) = builder.headers_mut() {
281+
// Remove the content-length header as we can't overwrite it after setting it
282+
let mut clean_headers = parts.headers.clone();
283+
clean_headers.remove(http::header::CONTENT_LENGTH);
284+
285+
headers.extend(clean_headers);
286+
}
287+
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
288+
encoder.write_all(&body::to_bytes(body).await.unwrap())?;
289+
let gzipped_body = encoder.finish()?;
290+
291+
builder
292+
// Write the new content-length header
293+
.header(http::header::CONTENT_LENGTH, gzipped_body.len().to_string())
294+
.header("content-encoding", "gzip")
295+
.body(hyper::Body::from(gzipped_body))?
296+
} else {
297+
app_response
298+
};
299+
240300
Ok(app_response)
241301
}
242302
}

tests/e2e/fixtures/go-httpbin-zip/template.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ Resources:
6161
Handler: bootstrap
6262
Runtime: provided.al2
6363
MemorySize: 256
64+
Environment:
65+
Variables:
66+
AWS_LWA_ENABLE_COMPRESSION: 'true'
6467
Layers:
6568
- !Ref AdapterLayerArn
6669
Events:

tests/e2e/fixtures/go-httpbin/template.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ Resources:
5656
Properties:
5757
PackageType: Image
5858
MemorySize: 256
59+
Environment:
60+
Variables:
61+
AWS_LWA_ENABLE_COMPRESSION: 'true'
5962
Events:
6063
HttpAPIEvent:
6164
Type: HttpApi

tests/e2e/main.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
use flate2::read::GzDecoder;
12
use http::Uri;
23
use hyper::{Body, Client, Method, Request};
34
use hyper_tls::HttpsConnector;
45
use lambda_http::aws_lambda_events::serde_json;
56
use lambda_http::aws_lambda_events::serde_json::Value;
67
use std::env;
8+
use std::io;
9+
use std::io::prelude::*;
710

811
fn get_endpoints() -> Vec<Option<String>> {
912
let configurations = [
@@ -20,6 +23,13 @@ fn get_endpoints() -> Vec<Option<String>> {
2023
configurations.iter().map(|e| env::var(e).ok()).collect()
2124
}
2225

26+
fn decode_reader(bytes: &Vec<u8>) -> io::Result<String> {
27+
let mut gz = GzDecoder::new(&bytes[..]);
28+
let mut s = String::new();
29+
gz.read_to_string(&mut s)?;
30+
Ok(s)
31+
}
32+
2333
#[ignore]
2434
#[tokio::test]
2535
async fn test_http_basic_request() {
@@ -88,3 +98,33 @@ async fn test_http_query_params() {
8898
}
8999
}
90100
}
101+
102+
#[ignore]
103+
#[tokio::test]
104+
async fn test_http_compress() {
105+
for endpoint in get_endpoints().iter() {
106+
if let Some(endpoint) = endpoint {
107+
let client = Client::builder().build::<_, hyper::Body>(HttpsConnector::new());
108+
let parts = endpoint.parse::<Uri>().unwrap().into_parts();
109+
let uri = Uri::builder()
110+
.scheme(parts.scheme.unwrap())
111+
.authority(parts.authority.unwrap())
112+
.path_and_query("/html")
113+
.build()
114+
.unwrap();
115+
let req = Request::builder()
116+
.method(Method::GET)
117+
.header("accept-encoding", "gzip")
118+
.uri(uri)
119+
.body(Body::empty())
120+
.unwrap();
121+
let resp = client.request(req).await.unwrap();
122+
let (parts, body) = resp.into_parts();
123+
let body_bytes = hyper::body::to_bytes(body).await.unwrap();
124+
let body = decode_reader(&body_bytes.to_vec()).unwrap();
125+
126+
assert_eq!(200, parts.status.as_u16());
127+
assert!(body.contains("<html>"));
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)