1use bytes::Bytes;
4use lambda_http::aws_lambda_events::apigw::ApiGatewayRequestAuthorizer;
5use lambda_http::aws_lambda_events::query_map::QueryMap;
6use lambda_http::{Request, RequestExt};
7use std::collections::HashMap;
8use tracing::warn;
9
10pub struct LambdaRequest {
12 pub method: http::Method,
14 pub path: String,
16 pub query_string: Option<String>,
18 pub headers: Vec<(String, String)>,
24 pub body: Bytes,
26 pub path_parameters: HashMap<String, String>,
28 pub stage_variables: HashMap<String, String>,
30 pub request_context: RequestContext,
32}
33
34#[derive(Debug, Clone, Default)]
36pub struct RequestContext {
37 pub request_id: Option<String>,
39 pub stage: Option<String>,
41 pub domain_name: Option<String>,
43 pub http_method: Option<String>,
45 pub source_ip: Option<String>,
47 pub user_agent: Option<String>,
49 pub authorizer_claims: HashMap<String, String>,
51}
52
53impl LambdaRequest {
54 pub fn from_lambda_request(request: Request) -> Self {
56 let path_parameters = query_map_to_hashmap(&request.path_parameters());
61 let stage_variables = query_map_to_hashmap(&request.stage_variables());
62
63 let (parts, body) = request.into_parts();
64
65 let mut headers = Vec::with_capacity(parts.headers.len());
68 for (name, value) in parts.headers.iter() {
69 match value.to_str() {
70 Ok(v) => headers.push((name.as_str().to_string(), v.to_string())),
71 Err(_) => warn!(
75 header = %name,
76 "Dropping request header with a non-UTF-8 value"
77 ),
78 }
79 }
80
81 let query_string = parts.uri.query().map(String::from);
83
84 let request_context = parts
86 .extensions
87 .get::<lambda_http::request::RequestContext>()
88 .map(|ctx| match ctx {
89 lambda_http::request::RequestContext::ApiGatewayV2(v2) => RequestContext {
90 request_id: v2.request_id.clone(),
91 stage: v2.stage.clone(),
92 domain_name: v2.domain_name.clone(),
93 http_method: Some(v2.http.method.to_string()),
94 source_ip: v2.http.source_ip.clone(),
95 user_agent: v2.http.user_agent.clone(),
96 authorizer_claims: v2
97 .authorizer
98 .as_ref()
99 .map(extract_claims)
100 .unwrap_or_default(),
101 },
102 lambda_http::request::RequestContext::ApiGatewayV1(v1) => RequestContext {
103 request_id: v1.request_id.clone(),
104 stage: v1.stage.clone(),
105 domain_name: v1.domain_name.clone(),
106 http_method: Some(v1.http_method.to_string()),
107 source_ip: v1.identity.source_ip.clone(),
108 user_agent: v1.identity.user_agent.clone(),
109 authorizer_claims: extract_claims(&v1.authorizer),
110 },
111 lambda_http::request::RequestContext::Alb(_) => RequestContext::default(),
112 _ => RequestContext::default(),
113 })
114 .unwrap_or_default();
115
116 let body_bytes = match body {
120 lambda_http::Body::Empty => Bytes::new(),
121 lambda_http::Body::Text(s) => Bytes::from(s),
122 lambda_http::Body::Binary(b) => Bytes::from(b),
123 _ => Bytes::new(),
124 };
125
126 Self {
127 method: parts.method,
128 path: parts.uri.path().to_string(),
129 query_string,
130 headers,
131 body: body_bytes,
132 path_parameters,
133 stage_variables,
134 request_context,
135 }
136 }
137
138 pub fn header(&self, name: &str) -> Option<&str> {
140 self.header_values(name).next()
141 }
142
143 pub fn header_values<'a, 'n>(
146 &'a self,
147 name: &'n str,
148 ) -> impl Iterator<Item = &'a str> + use<'a, 'n> {
149 self.headers
150 .iter()
151 .filter(move |(n, _)| n.eq_ignore_ascii_case(name))
152 .map(|(_, v)| v.as_str())
153 }
154
155 pub fn content_type(&self) -> Option<&str> {
157 self.header("content-type")
158 }
159
160 pub fn is_json(&self) -> bool {
162 self.content_type()
163 .map(|ct| ct.contains("application/json"))
164 .unwrap_or(false)
165 }
166
167 pub fn source_ip(&self) -> Option<&str> {
169 self.request_context.source_ip.as_deref()
170 }
171
172 pub fn path_parameter(&self, name: &str) -> Option<&str> {
174 self.path_parameters.get(name).map(|s| s.as_str())
175 }
176
177 pub fn stage_variable(&self, name: &str) -> Option<&str> {
179 self.stage_variables.get(name).map(|s| s.as_str())
180 }
181
182 pub fn claims(&self) -> &HashMap<String, String> {
184 &self.request_context.authorizer_claims
185 }
186
187 pub fn claim(&self, key: &str) -> Option<&str> {
189 self.request_context
190 .authorizer_claims
191 .get(key)
192 .map(|s| s.as_str())
193 }
194}
195
196fn query_map_to_hashmap(map: &QueryMap) -> HashMap<String, String> {
199 let mut out = HashMap::new();
200 for (key, value) in map.iter() {
201 out.entry(key.to_string())
202 .or_insert_with(|| value.to_string());
203 }
204 out
205}
206
207fn extract_claims(authorizer: &ApiGatewayRequestAuthorizer) -> HashMap<String, String> {
214 if let Some(jwt) = &authorizer.jwt
215 && !jwt.claims.is_empty()
216 {
217 return jwt.claims.clone();
218 }
219
220 if let Some(serde_json::Value::Object(claims)) = authorizer.fields.get("claims") {
221 return claims
222 .iter()
223 .filter_map(|(key, value)| match value {
224 serde_json::Value::String(s) => Some((key.clone(), s.clone())),
225 serde_json::Value::Null => None,
226 other => Some((key.clone(), other.to_string())),
227 })
228 .collect();
229 }
230
231 HashMap::new()
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use lambda_http::Body;
238 use lambda_http::request::RequestContext as HttpRequestContext;
239
240 fn v2_context_json() -> &'static str {
241 r#"{
242 "routeKey": "POST /users/{id}",
243 "accountId": "123456789012",
244 "stage": "$default",
245 "requestId": "req-v2-1",
246 "authorizer": {
247 "jwt": {
248 "claims": { "sub": "user-123", "email": "user@example.com" },
249 "scopes": ["read"]
250 }
251 },
252 "apiId": "abcd1234",
253 "domainName": "api.example.com",
254 "http": {
255 "method": "POST",
256 "path": "/users/42",
257 "protocol": "HTTP/1.1",
258 "sourceIp": "203.0.113.7",
259 "userAgent": "test-agent/1.0"
260 },
261 "timeEpoch": 0
262 }"#
263 }
264
265 fn v1_context_json() -> &'static str {
266 r#"{
267 "accountId": "123456789012",
268 "resourceId": "abc123",
269 "stage": "prod",
270 "requestId": "req-v1-1",
271 "domainName": "api.example.com",
272 "identity": {
273 "sourceIp": "198.51.100.9",
274 "userAgent": "rest-agent/2.0"
275 },
276 "authorizer": {
277 "claims": {
278 "sub": "cognito-user-9",
279 "cognito:username": "alice"
280 }
281 },
282 "resourcePath": "/users/{id}",
283 "httpMethod": "POST",
284 "apiId": "restapi1"
285 }"#
286 }
287
288 fn make_v2_request() -> Request {
289 let ctx: lambda_http::aws_lambda_events::apigw::ApiGatewayV2httpRequestContext =
290 serde_json::from_str(v2_context_json()).expect("v2 context deserializes");
291
292 let mut path_params = HashMap::new();
293 path_params.insert("id".to_string(), "42".to_string());
294
295 let mut stage_vars = HashMap::new();
296 stage_vars.insert("env".to_string(), "staging".to_string());
297
298 http::Request::builder()
299 .method("POST")
300 .uri("https://api.example.com/users/42?page=2")
301 .header("content-type", "application/json")
302 .header("x-custom", "hello")
303 .body(Body::Text("{\"name\":\"a\"}".to_string()))
304 .unwrap()
305 .with_path_parameters(path_params)
306 .with_stage_variables(stage_vars)
307 .with_request_context(HttpRequestContext::ApiGatewayV2(ctx))
308 }
309
310 fn make_v1_request() -> Request {
311 let ctx: lambda_http::aws_lambda_events::apigw::ApiGatewayProxyRequestContext =
312 serde_json::from_str(v1_context_json()).expect("v1 context deserializes");
313
314 let mut path_params = HashMap::new();
315 path_params.insert("id".to_string(), "42".to_string());
316
317 let mut stage_vars = HashMap::new();
318 stage_vars.insert("region".to_string(), "us-east-1".to_string());
319
320 http::Request::builder()
321 .method("POST")
322 .uri("https://api.example.com/users/42")
323 .header("content-type", "application/json")
324 .body(Body::Text("body-1".to_string()))
325 .unwrap()
326 .with_path_parameters(path_params)
327 .with_stage_variables(stage_vars)
328 .with_request_context(HttpRequestContext::ApiGatewayV1(ctx))
329 }
330
331 #[test]
332 fn v2_claims_are_populated_from_jwt() {
333 let req = LambdaRequest::from_lambda_request(make_v2_request());
334
335 assert_eq!(req.claim("sub"), Some("user-123"));
336 assert_eq!(req.claim("email"), Some("user@example.com"));
337 assert_eq!(req.claims().len(), 2);
338 }
339
340 #[test]
341 fn v1_claims_are_populated_from_authorizer_map() {
342 let req = LambdaRequest::from_lambda_request(make_v1_request());
343
344 assert_eq!(req.claim("sub"), Some("cognito-user-9"));
345 assert_eq!(req.claim("cognito:username"), Some("alice"));
346 assert_eq!(req.claims().len(), 2);
347 }
348
349 #[test]
350 fn v2_path_parameters_and_stage_variables_are_extracted() {
351 let req = LambdaRequest::from_lambda_request(make_v2_request());
352
353 assert_eq!(req.path_parameter("id"), Some("42"));
354 assert_eq!(req.stage_variable("env"), Some("staging"));
355 }
356
357 #[test]
358 fn v1_path_parameters_and_stage_variables_are_extracted() {
359 let req = LambdaRequest::from_lambda_request(make_v1_request());
360
361 assert_eq!(req.path_parameter("id"), Some("42"));
362 assert_eq!(req.stage_variable("region"), Some("us-east-1"));
363 }
364
365 #[test]
366 fn context_and_headers_and_body_are_mapped() {
367 let req = LambdaRequest::from_lambda_request(make_v2_request());
368
369 assert_eq!(req.method, http::Method::POST);
370 assert_eq!(req.path, "/users/42");
371 assert_eq!(req.query_string.as_deref(), Some("page=2"));
372 assert_eq!(req.header("content-type"), Some("application/json"));
373 assert_eq!(req.header("x-custom"), Some("hello"));
374 assert!(req.is_json());
375 assert_eq!(&req.body[..], b"{\"name\":\"a\"}");
376
377 assert_eq!(req.request_context.request_id.as_deref(), Some("req-v2-1"));
378 assert_eq!(req.request_context.stage.as_deref(), Some("$default"));
379 assert_eq!(req.source_ip(), Some("203.0.113.7"));
380 assert_eq!(
381 req.request_context.user_agent.as_deref(),
382 Some("test-agent/1.0")
383 );
384 }
385
386 #[test]
387 fn repeated_request_headers_are_all_preserved() {
388 let req = LambdaRequest::from_lambda_request(
389 http::Request::builder()
390 .method("GET")
391 .uri("https://api.example.com/x")
392 .header("cookie", "a=1")
393 .header("cookie", "b=2")
394 .body(Body::Empty)
395 .unwrap(),
396 );
397
398 assert_eq!(req.header("cookie"), Some("a=1"));
399 assert_eq!(
400 req.header_values("Cookie").collect::<Vec<_>>(),
401 ["a=1", "b=2"]
402 );
403 }
404
405 #[test]
406 fn missing_context_yields_empty_defaults() {
407 let req = LambdaRequest::from_lambda_request(
408 http::Request::builder()
409 .method("GET")
410 .uri("https://api.example.com/health")
411 .body(Body::Empty)
412 .unwrap(),
413 );
414
415 assert!(req.claims().is_empty());
416 assert!(req.path_parameters.is_empty());
417 assert!(req.stage_variables.is_empty());
418 assert_eq!(req.body.len(), 0);
419 }
420}