Skip to main content

armature_lambda/
request.rs

1//! Lambda request conversion.
2
3use 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
9/// Wrapper for Lambda HTTP requests.
10pub struct LambdaRequest {
11    /// HTTP method.
12    pub method: http::Method,
13    /// Request path.
14    pub path: String,
15    /// Query string.
16    pub query_string: Option<String>,
17    /// Headers.
18    pub headers: HashMap<String, String>,
19    /// Request body.
20    pub body: Bytes,
21    /// Path parameters (from API Gateway).
22    pub path_parameters: HashMap<String, String>,
23    /// Stage variables (from API Gateway).
24    pub stage_variables: HashMap<String, String>,
25    /// Request context.
26    pub request_context: RequestContext,
27}
28
29/// Request context from API Gateway.
30#[derive(Debug, Clone, Default)]
31pub struct RequestContext {
32    /// Request ID.
33    pub request_id: Option<String>,
34    /// Stage name.
35    pub stage: Option<String>,
36    /// Domain name.
37    pub domain_name: Option<String>,
38    /// HTTP method.
39    pub http_method: Option<String>,
40    /// Source IP.
41    pub source_ip: Option<String>,
42    /// User agent.
43    pub user_agent: Option<String>,
44    /// Authorizer claims (for Cognito / JWT authorizers).
45    pub authorizer_claims: HashMap<String, String>,
46}
47
48impl LambdaRequest {
49    /// Create from a lambda_http::Request.
50    pub fn from_lambda_request(request: Request) -> Self {
51        // Path parameters and stage variables are pre-extracted by
52        // `lambda_http` into request extensions for both API Gateway REST
53        // (V1) and HTTP (V2) events, so read them before consuming the
54        // request into parts.
55        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        // Extract headers
61        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        // Extract query string
69        let query_string = parts.uri.query().map(String::from);
70
71        // Extract request context (including authorizer claims) from extensions.
72        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        // Convert body. lambda_http::Body is #[non_exhaustive] so we
104        // need a wildcard arm — fall back to an empty body on any
105        // future variant rather than panicking.
106        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    /// Get a header value.
126    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    /// Get the content type.
134    pub fn content_type(&self) -> Option<&str> {
135        self.header("content-type")
136    }
137
138    /// Check if the request is JSON.
139    pub fn is_json(&self) -> bool {
140        self.content_type()
141            .map(|ct| ct.contains("application/json"))
142            .unwrap_or(false)
143    }
144
145    /// Get the source IP.
146    pub fn source_ip(&self) -> Option<&str> {
147        self.request_context.source_ip.as_deref()
148    }
149
150    /// Get a path parameter.
151    pub fn path_parameter(&self, name: &str) -> Option<&str> {
152        self.path_parameters.get(name).map(|s| s.as_str())
153    }
154
155    /// Get a stage variable.
156    pub fn stage_variable(&self, name: &str) -> Option<&str> {
157        self.stage_variables.get(name).map(|s| s.as_str())
158    }
159
160    /// Get authorizer claims (for Cognito / JWT authorizers).
161    pub fn claims(&self) -> &HashMap<String, String> {
162        &self.request_context.authorizer_claims
163    }
164
165    /// Get a specific claim.
166    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
174/// Convert a `lambda_http` `QueryMap` (path parameters / stage variables) into
175/// a flat `HashMap`. When a key is multi-valued the first value wins.
176fn 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
185/// Extract authorizer claims from an API Gateway request authorizer.
186///
187/// Handles both shapes:
188/// - HTTP API (V2) and REST API JWT authorizers expose `authorizer.jwt.claims`.
189/// - REST API (V1) Cognito / custom authorizers expose the claims as a nested
190///   `claims` object inside the raw `authorizer` map (captured in `fields`).
191fn 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}