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;
8use tracing::warn;
9
10/// Wrapper for Lambda HTTP requests.
11pub struct LambdaRequest {
12    /// HTTP method.
13    pub method: http::Method,
14    /// Request path.
15    pub path: String,
16    /// Query string.
17    pub query_string: Option<String>,
18    /// Headers, in the order they arrived.
19    ///
20    /// A list rather than a map because a request may legitimately carry the
21    /// same field name more than once (`Cookie`, `X-Forwarded-For`, `Via`), and
22    /// a map would keep only the last of them.
23    pub headers: Vec<(String, String)>,
24    /// Request body.
25    pub body: Bytes,
26    /// Path parameters (from API Gateway).
27    pub path_parameters: HashMap<String, String>,
28    /// Stage variables (from API Gateway).
29    pub stage_variables: HashMap<String, String>,
30    /// Request context.
31    pub request_context: RequestContext,
32}
33
34/// Request context from API Gateway.
35#[derive(Debug, Clone, Default)]
36pub struct RequestContext {
37    /// Request ID.
38    pub request_id: Option<String>,
39    /// Stage name.
40    pub stage: Option<String>,
41    /// Domain name.
42    pub domain_name: Option<String>,
43    /// HTTP method.
44    pub http_method: Option<String>,
45    /// Source IP.
46    pub source_ip: Option<String>,
47    /// User agent.
48    pub user_agent: Option<String>,
49    /// Authorizer claims (for Cognito / JWT authorizers).
50    pub authorizer_claims: HashMap<String, String>,
51}
52
53impl LambdaRequest {
54    /// Create from a lambda_http::Request.
55    pub fn from_lambda_request(request: Request) -> Self {
56        // Path parameters and stage variables are pre-extracted by
57        // `lambda_http` into request extensions for both API Gateway REST
58        // (V1) and HTTP (V2) events, so read them before consuming the
59        // request into parts.
60        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        // Extract headers. `HeaderMap::iter` yields one entry per value, so
66        // repeated field names survive into the list intact.
67        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                // The facade hands out `&str`, so a value that is not UTF-8
72                // cannot be represented. Say so instead of dropping it in
73                // silence — an unexplained missing header is very hard to debug.
74                Err(_) => warn!(
75                    header = %name,
76                    "Dropping request header with a non-UTF-8 value"
77                ),
78            }
79        }
80
81        // Extract query string
82        let query_string = parts.uri.query().map(String::from);
83
84        // Extract request context (including authorizer claims) from extensions.
85        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        // Convert body. lambda_http::Body is #[non_exhaustive] so we
117        // need a wildcard arm — fall back to an empty body on any
118        // future variant rather than panicking.
119        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    /// Get the first value for a header, matched case-insensitively.
139    pub fn header(&self, name: &str) -> Option<&str> {
140        self.header_values(name).next()
141    }
142
143    /// Get every value for a header, in arrival order, matched
144    /// case-insensitively. Use this for names that legitimately repeat.
145    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    /// Get the content type.
156    pub fn content_type(&self) -> Option<&str> {
157        self.header("content-type")
158    }
159
160    /// Check if the request is JSON.
161    pub fn is_json(&self) -> bool {
162        self.content_type()
163            .map(|ct| ct.contains("application/json"))
164            .unwrap_or(false)
165    }
166
167    /// Get the source IP.
168    pub fn source_ip(&self) -> Option<&str> {
169        self.request_context.source_ip.as_deref()
170    }
171
172    /// Get a path parameter.
173    pub fn path_parameter(&self, name: &str) -> Option<&str> {
174        self.path_parameters.get(name).map(|s| s.as_str())
175    }
176
177    /// Get a stage variable.
178    pub fn stage_variable(&self, name: &str) -> Option<&str> {
179        self.stage_variables.get(name).map(|s| s.as_str())
180    }
181
182    /// Get authorizer claims (for Cognito / JWT authorizers).
183    pub fn claims(&self) -> &HashMap<String, String> {
184        &self.request_context.authorizer_claims
185    }
186
187    /// Get a specific claim.
188    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
196/// Convert a `lambda_http` `QueryMap` (path parameters / stage variables) into
197/// a flat `HashMap`. When a key is multi-valued the first value wins.
198fn 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
207/// Extract authorizer claims from an API Gateway request authorizer.
208///
209/// Handles both shapes:
210/// - HTTP API (V2) and REST API JWT authorizers expose `authorizer.jwt.claims`.
211/// - REST API (V1) Cognito / custom authorizers expose the claims as a nested
212///   `claims` object inside the raw `authorizer` map (captured in `fields`).
213fn 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}