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;
8
9pub struct LambdaRequest {
11 pub method: http::Method,
13 pub path: String,
15 pub query_string: Option<String>,
17 pub headers: HashMap<String, String>,
19 pub body: Bytes,
21 pub path_parameters: HashMap<String, String>,
23 pub stage_variables: HashMap<String, String>,
25 pub request_context: RequestContext,
27}
28
29#[derive(Debug, Clone, Default)]
31pub struct RequestContext {
32 pub request_id: Option<String>,
34 pub stage: Option<String>,
36 pub domain_name: Option<String>,
38 pub http_method: Option<String>,
40 pub source_ip: Option<String>,
42 pub user_agent: Option<String>,
44 pub authorizer_claims: HashMap<String, String>,
46}
47
48impl LambdaRequest {
49 pub fn from_lambda_request(request: Request) -> Self {
51 let path_parameters = query_map_to_hashmap(&request.path_parameters());
56 let stage_variables = query_map_to_hashmap(&request.stage_variables());
57
58 let (parts, body) = request.into_parts();
59
60 let mut headers = HashMap::new();
62 for (name, value) in parts.headers.iter() {
63 if let Ok(v) = value.to_str() {
64 headers.insert(name.to_string(), v.to_string());
65 }
66 }
67
68 let query_string = parts.uri.query().map(String::from);
70
71 let request_context = parts
73 .extensions
74 .get::<lambda_http::request::RequestContext>()
75 .map(|ctx| match ctx {
76 lambda_http::request::RequestContext::ApiGatewayV2(v2) => RequestContext {
77 request_id: v2.request_id.clone(),
78 stage: v2.stage.clone(),
79 domain_name: v2.domain_name.clone(),
80 http_method: Some(v2.http.method.to_string()),
81 source_ip: v2.http.source_ip.clone(),
82 user_agent: v2.http.user_agent.clone(),
83 authorizer_claims: v2
84 .authorizer
85 .as_ref()
86 .map(extract_claims)
87 .unwrap_or_default(),
88 },
89 lambda_http::request::RequestContext::ApiGatewayV1(v1) => RequestContext {
90 request_id: v1.request_id.clone(),
91 stage: v1.stage.clone(),
92 domain_name: v1.domain_name.clone(),
93 http_method: Some(v1.http_method.to_string()),
94 source_ip: v1.identity.source_ip.clone(),
95 user_agent: v1.identity.user_agent.clone(),
96 authorizer_claims: extract_claims(&v1.authorizer),
97 },
98 lambda_http::request::RequestContext::Alb(_) => RequestContext::default(),
99 _ => RequestContext::default(),
100 })
101 .unwrap_or_default();
102
103 let body_bytes = match body {
107 lambda_http::Body::Empty => Bytes::new(),
108 lambda_http::Body::Text(s) => Bytes::from(s),
109 lambda_http::Body::Binary(b) => Bytes::from(b),
110 _ => Bytes::new(),
111 };
112
113 Self {
114 method: parts.method,
115 path: parts.uri.path().to_string(),
116 query_string,
117 headers,
118 body: body_bytes,
119 path_parameters,
120 stage_variables,
121 request_context,
122 }
123 }
124
125 pub fn header(&self, name: &str) -> Option<&str> {
127 self.headers
128 .get(&name.to_lowercase())
129 .or_else(|| self.headers.get(name))
130 .map(|s| s.as_str())
131 }
132
133 pub fn content_type(&self) -> Option<&str> {
135 self.header("content-type")
136 }
137
138 pub fn is_json(&self) -> bool {
140 self.content_type()
141 .map(|ct| ct.contains("application/json"))
142 .unwrap_or(false)
143 }
144
145 pub fn source_ip(&self) -> Option<&str> {
147 self.request_context.source_ip.as_deref()
148 }
149
150 pub fn path_parameter(&self, name: &str) -> Option<&str> {
152 self.path_parameters.get(name).map(|s| s.as_str())
153 }
154
155 pub fn stage_variable(&self, name: &str) -> Option<&str> {
157 self.stage_variables.get(name).map(|s| s.as_str())
158 }
159
160 pub fn claims(&self) -> &HashMap<String, String> {
162 &self.request_context.authorizer_claims
163 }
164
165 pub fn claim(&self, key: &str) -> Option<&str> {
167 self.request_context
168 .authorizer_claims
169 .get(key)
170 .map(|s| s.as_str())
171 }
172}
173
174fn query_map_to_hashmap(map: &QueryMap) -> HashMap<String, String> {
177 let mut out = HashMap::new();
178 for (key, value) in map.iter() {
179 out.entry(key.to_string())
180 .or_insert_with(|| value.to_string());
181 }
182 out
183}
184
185fn extract_claims(authorizer: &ApiGatewayRequestAuthorizer) -> HashMap<String, String> {
192 if let Some(jwt) = &authorizer.jwt
193 && !jwt.claims.is_empty()
194 {
195 return jwt.claims.clone();
196 }
197
198 if let Some(serde_json::Value::Object(claims)) = authorizer.fields.get("claims") {
199 return claims
200 .iter()
201 .filter_map(|(key, value)| match value {
202 serde_json::Value::String(s) => Some((key.clone(), s.clone())),
203 serde_json::Value::Null => None,
204 other => Some((key.clone(), other.to_string())),
205 })
206 .collect();
207 }
208
209 HashMap::new()
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use lambda_http::Body;
216 use lambda_http::request::RequestContext as HttpRequestContext;
217
218 fn v2_context_json() -> &'static str {
219 r#"{
220 "routeKey": "POST /users/{id}",
221 "accountId": "123456789012",
222 "stage": "$default",
223 "requestId": "req-v2-1",
224 "authorizer": {
225 "jwt": {
226 "claims": { "sub": "user-123", "email": "user@example.com" },
227 "scopes": ["read"]
228 }
229 },
230 "apiId": "abcd1234",
231 "domainName": "api.example.com",
232 "http": {
233 "method": "POST",
234 "path": "/users/42",
235 "protocol": "HTTP/1.1",
236 "sourceIp": "203.0.113.7",
237 "userAgent": "test-agent/1.0"
238 },
239 "timeEpoch": 0
240 }"#
241 }
242
243 fn v1_context_json() -> &'static str {
244 r#"{
245 "accountId": "123456789012",
246 "resourceId": "abc123",
247 "stage": "prod",
248 "requestId": "req-v1-1",
249 "domainName": "api.example.com",
250 "identity": {
251 "sourceIp": "198.51.100.9",
252 "userAgent": "rest-agent/2.0"
253 },
254 "authorizer": {
255 "claims": {
256 "sub": "cognito-user-9",
257 "cognito:username": "alice"
258 }
259 },
260 "resourcePath": "/users/{id}",
261 "httpMethod": "POST",
262 "apiId": "restapi1"
263 }"#
264 }
265
266 fn make_v2_request() -> Request {
267 let ctx: lambda_http::aws_lambda_events::apigw::ApiGatewayV2httpRequestContext =
268 serde_json::from_str(v2_context_json()).expect("v2 context deserializes");
269
270 let mut path_params = HashMap::new();
271 path_params.insert("id".to_string(), "42".to_string());
272
273 let mut stage_vars = HashMap::new();
274 stage_vars.insert("env".to_string(), "staging".to_string());
275
276 http::Request::builder()
277 .method("POST")
278 .uri("https://api.example.com/users/42?page=2")
279 .header("content-type", "application/json")
280 .header("x-custom", "hello")
281 .body(Body::Text("{\"name\":\"a\"}".to_string()))
282 .unwrap()
283 .with_path_parameters(path_params)
284 .with_stage_variables(stage_vars)
285 .with_request_context(HttpRequestContext::ApiGatewayV2(ctx))
286 }
287
288 fn make_v1_request() -> Request {
289 let ctx: lambda_http::aws_lambda_events::apigw::ApiGatewayProxyRequestContext =
290 serde_json::from_str(v1_context_json()).expect("v1 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("region".to_string(), "us-east-1".to_string());
297
298 http::Request::builder()
299 .method("POST")
300 .uri("https://api.example.com/users/42")
301 .header("content-type", "application/json")
302 .body(Body::Text("body-1".to_string()))
303 .unwrap()
304 .with_path_parameters(path_params)
305 .with_stage_variables(stage_vars)
306 .with_request_context(HttpRequestContext::ApiGatewayV1(ctx))
307 }
308
309 #[test]
310 fn v2_claims_are_populated_from_jwt() {
311 let req = LambdaRequest::from_lambda_request(make_v2_request());
312
313 assert_eq!(req.claim("sub"), Some("user-123"));
314 assert_eq!(req.claim("email"), Some("user@example.com"));
315 assert_eq!(req.claims().len(), 2);
316 }
317
318 #[test]
319 fn v1_claims_are_populated_from_authorizer_map() {
320 let req = LambdaRequest::from_lambda_request(make_v1_request());
321
322 assert_eq!(req.claim("sub"), Some("cognito-user-9"));
323 assert_eq!(req.claim("cognito:username"), Some("alice"));
324 assert_eq!(req.claims().len(), 2);
325 }
326
327 #[test]
328 fn v2_path_parameters_and_stage_variables_are_extracted() {
329 let req = LambdaRequest::from_lambda_request(make_v2_request());
330
331 assert_eq!(req.path_parameter("id"), Some("42"));
332 assert_eq!(req.stage_variable("env"), Some("staging"));
333 }
334
335 #[test]
336 fn v1_path_parameters_and_stage_variables_are_extracted() {
337 let req = LambdaRequest::from_lambda_request(make_v1_request());
338
339 assert_eq!(req.path_parameter("id"), Some("42"));
340 assert_eq!(req.stage_variable("region"), Some("us-east-1"));
341 }
342
343 #[test]
344 fn context_and_headers_and_body_are_mapped() {
345 let req = LambdaRequest::from_lambda_request(make_v2_request());
346
347 assert_eq!(req.method, http::Method::POST);
348 assert_eq!(req.path, "/users/42");
349 assert_eq!(req.query_string.as_deref(), Some("page=2"));
350 assert_eq!(req.header("content-type"), Some("application/json"));
351 assert_eq!(req.header("x-custom"), Some("hello"));
352 assert!(req.is_json());
353 assert_eq!(&req.body[..], b"{\"name\":\"a\"}");
354
355 assert_eq!(req.request_context.request_id.as_deref(), Some("req-v2-1"));
356 assert_eq!(req.request_context.stage.as_deref(), Some("$default"));
357 assert_eq!(req.source_ip(), Some("203.0.113.7"));
358 assert_eq!(
359 req.request_context.user_agent.as_deref(),
360 Some("test-agent/1.0")
361 );
362 }
363
364 #[test]
365 fn missing_context_yields_empty_defaults() {
366 let req = LambdaRequest::from_lambda_request(
367 http::Request::builder()
368 .method("GET")
369 .uri("https://api.example.com/health")
370 .body(Body::Empty)
371 .unwrap(),
372 );
373
374 assert!(req.claims().is_empty());
375 assert!(req.path_parameters.is_empty());
376 assert!(req.stage_variables.is_empty());
377 assert_eq!(req.body.len(), 0);
378 }
379}