Skip to main content

turul_mcp_aws_lambda/
adapter.rs

1//! HTTP type conversion utilities for Lambda MCP requests
2//!
3//! This module provides comprehensive conversion between lambda_http and hyper types,
4//! enabling seamless integration between Lambda's HTTP model and the SessionMcpHandler.
5
6use std::collections::HashMap;
7use std::str::FromStr;
8
9use bytes::Bytes;
10use http_body_util::{BodyExt, Full};
11use hyper::Response as HyperResponse;
12use lambda_http::{Body as LambdaBody, Request as LambdaRequest, Response as LambdaResponse};
13use tracing::{debug, trace};
14
15use crate::error::{LambdaError, Result};
16
17/// Type alias for the unified MCP response body used by SessionMcpHandler
18type UnifiedMcpBody = http_body_util::combinators::UnsyncBoxBody<Bytes, hyper::Error>;
19
20/// Error mapping function for Full<Bytes>
21fn infallible_to_hyper_error(never: std::convert::Infallible) -> hyper::Error {
22    match never {}
23}
24
25/// Type alias for Full<Bytes> with mapped error type compatible with SessionMcpHandler
26type MappedFullBody =
27    http_body_util::combinators::MapErr<Full<Bytes>, fn(std::convert::Infallible) -> hyper::Error>;
28
29/// Convert lambda_http::Request to hyper::Request<MappedFullBody>
30///
31/// This enables delegation to SessionMcpHandler by converting Lambda's request format
32/// to the hyper format expected by the framework. All headers are preserved, and Lambda
33/// authorizer context (if present) is extracted and injected as `x-authorizer-*` headers.
34///
35/// # Authorizer Context
36///
37/// If the request includes API Gateway authorizer context, fields are extracted and
38/// added as headers with the `x-authorizer-` prefix. This makes authorizer data
39/// available to middleware via `RequestContext.metadata`.
40///
41/// Field names are sanitized (lowercase, alphanumeric + dash/underscore only).
42/// Invalid header names/values are skipped gracefully.
43pub fn lambda_to_hyper_request(
44    lambda_req: LambdaRequest,
45) -> Result<hyper::Request<MappedFullBody>> {
46    // Extract authorizer context BEFORE consuming request
47    let authorizer_fields = extract_authorizer_context(&lambda_req);
48
49    // Convert to parts (consumes request)
50    let (mut parts, lambda_body) = lambda_req.into_parts();
51
52    // Inject authorizer fields as x-authorizer-* headers (defensive - skip failures)
53    for (field_name, field_value) in authorizer_fields {
54        let header_name = format!("x-authorizer-{}", field_name);
55
56        // Try to create HeaderName and HeaderValue
57        // Skip entry if either fails (defensive - don't break request)
58        let Ok(name) = http::HeaderName::from_str(&header_name) else {
59            debug!(
60                "Skipping authorizer field '{}' - invalid header name",
61                field_name
62            );
63            continue;
64        };
65
66        let Ok(value) = http::HeaderValue::from_str(&field_value) else {
67            debug!(
68                "Skipping authorizer field '{}' - invalid header value",
69                field_name
70            );
71            continue;
72        };
73
74        parts.headers.insert(name, value);
75        trace!(
76            "Injected authorizer header: {} = {}",
77            header_name, field_value
78        );
79    }
80
81    // Convert LambdaBody to Bytes
82    let body_bytes = match lambda_body {
83        LambdaBody::Empty => Bytes::new(),
84        LambdaBody::Text(s) => Bytes::from(s),
85        LambdaBody::Binary(b) => Bytes::from(b),
86        _ => Bytes::new(),
87    };
88
89    // Create Full<Bytes> body and map error type to hyper::Error
90    let full_body = Full::new(body_bytes)
91        .map_err(infallible_to_hyper_error as fn(std::convert::Infallible) -> hyper::Error);
92
93    // Create hyper Request with enhanced headers
94    let hyper_req = hyper::Request::from_parts(parts, full_body);
95
96    debug!(
97        "Converted Lambda request: {} {} -> hyper::Request<Full<Bytes>>",
98        hyper_req.method(),
99        hyper_req.uri()
100    );
101
102    Ok(hyper_req)
103}
104
105/// Convert hyper::Response<UnifiedMcpBody> to lambda_http::Response<LambdaBody>
106///
107/// This collects the streaming body into a LambdaBody for non-streaming responses.
108/// Used by the handle() method which returns snapshot responses.
109pub async fn hyper_to_lambda_response(
110    hyper_resp: HyperResponse<UnifiedMcpBody>,
111) -> Result<LambdaResponse<LambdaBody>> {
112    let (parts, body) = hyper_resp.into_parts();
113
114    // Collect the body into bytes
115    let body_bytes = match body.collect().await {
116        Ok(collected) => collected.to_bytes(),
117        Err(err) => {
118            return Err(LambdaError::Body(format!(
119                "Failed to collect response body: {}",
120                err
121            )));
122        }
123    };
124
125    // Convert to LambdaBody
126    let lambda_body = if body_bytes.is_empty() {
127        LambdaBody::Empty
128    } else {
129        // Try to convert to text if it's valid UTF-8, otherwise use binary
130        match String::from_utf8(body_bytes.to_vec()) {
131            Ok(text) => LambdaBody::Text(text),
132            Err(_) => LambdaBody::Binary(body_bytes.to_vec()),
133        }
134    };
135
136    // Create Lambda response with preserved headers
137    let lambda_resp = LambdaResponse::from_parts(parts, lambda_body);
138
139    debug!(
140        "Converted hyper response -> Lambda response (status: {})",
141        lambda_resp.status()
142    );
143
144    Ok(lambda_resp)
145}
146
147/// Convert hyper::Response<UnifiedMcpBody> to lambda_http streaming response
148///
149/// This preserves the streaming body for real-time SSE responses.
150/// Used by the handle_streaming() method for true streaming.
151pub fn hyper_to_lambda_streaming(
152    hyper_resp: HyperResponse<UnifiedMcpBody>,
153) -> lambda_http::Response<UnifiedMcpBody> {
154    let (parts, body) = hyper_resp.into_parts();
155
156    // Direct passthrough - no body collection, preserves streaming
157    let lambda_resp = lambda_http::Response::from_parts(parts, body);
158
159    debug!(
160        "Converted hyper response -> Lambda streaming response (status: {})",
161        lambda_resp.status()
162    );
163
164    lambda_resp
165}
166
167/// Convert camelCase or PascalCase to snake_case
168///
169/// # Examples
170///
171/// ```no_run
172/// # use turul_mcp_aws_lambda::adapter::camel_to_snake;
173/// assert_eq!(camel_to_snake("userId"), "user_id");
174/// assert_eq!(camel_to_snake("deviceId"), "device_id");
175/// assert_eq!(camel_to_snake("APIKey"), "api_key");
176/// assert_eq!(camel_to_snake("HTTPSEnabled"), "https_enabled");
177/// assert_eq!(camel_to_snake("user_id"), "user_id");
178/// ```
179pub fn camel_to_snake(s: &str) -> String {
180    let mut result = String::new();
181    let chars: Vec<char> = s.chars().collect();
182
183    for i in 0..chars.len() {
184        let ch = chars[i];
185
186        if ch.is_uppercase() {
187            let is_first = i == 0;
188            let prev_is_lower = i > 0 && chars[i - 1].is_lowercase();
189            let next_is_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase();
190
191            // Add underscore before uppercase if:
192            // - Not at start AND
193            // - (Previous was lowercase OR next is lowercase)
194            if !is_first && (prev_is_lower || next_is_lower) {
195                result.push('_');
196            }
197
198            result.push(ch.to_ascii_lowercase());
199        } else {
200            result.push(ch);
201        }
202    }
203
204    result
205}
206
207/// Sanitize authorizer field name for use in HTTP headers
208///
209/// Converts field names to valid HTTP header format:
210/// 1. Convert camelCase to snake_case (userId → user_id)
211/// 2. ASCII lowercase
212/// 3. Replace non-alphanumeric (except _ and -) with dash
213///
214/// # Examples
215///
216/// ```no_run
217/// # use turul_mcp_aws_lambda::adapter::sanitize_authorizer_field_name;
218/// assert_eq!(sanitize_authorizer_field_name("userId"), "user_id");
219/// assert_eq!(sanitize_authorizer_field_name("deviceId"), "device_id");
220/// assert_eq!(sanitize_authorizer_field_name("device_id"), "device_id");
221/// assert_eq!(sanitize_authorizer_field_name("user@email"), "user-email");
222/// ```
223pub fn sanitize_authorizer_field_name(field: &str) -> String {
224    // Step 1: Convert camelCase to snake_case
225    let snake_case = camel_to_snake(field);
226
227    // Step 2: Sanitize for HTTP header compatibility
228    snake_case
229        .to_ascii_lowercase()
230        .chars()
231        .map(|c| {
232            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
233                c
234            } else {
235                '-'
236            }
237        })
238        .collect()
239}
240
241/// Extract authorizer context from Lambda request extensions
242///
243/// Supports both API Gateway V1 (REST API) and V2 (HTTP API) formats.
244/// Returns HashMap with snake_case keys ready for header injection.
245///
246/// # Behavior
247///
248/// - Returns empty HashMap if no authorizer context present
249/// - Converts camelCase to snake_case (userId → user_id)
250/// - Skips fields that fail sanitization
251/// - Converts non-string values to JSON strings
252/// - Handles `ApiGateway.authorizer.fields["lambda"]` (V1 nested) or direct fields (V1 flat)
253/// - Handles `ApiGatewayV2.authorizer.fields` (V2, deserialized from "lambda" key by serde)
254///
255/// # Examples
256///
257/// ```no_run
258/// # use lambda_http::Request;
259/// # use turul_mcp_aws_lambda::adapter::extract_authorizer_context;
260/// # let request: Request = unimplemented!();
261/// let fields = extract_authorizer_context(&request);
262/// assert_eq!(fields.get("account_id"), Some(&"acc_123".to_string()));
263/// ```
264pub fn extract_authorizer_context(req: &LambdaRequest) -> HashMap<String, String> {
265    use lambda_http::request::RequestContext;
266
267    let mut fields = HashMap::new();
268
269    // Get RequestContext from extensions
270    let Some(request_context) = req.extensions().get::<RequestContext>() else {
271        return fields; // No context, return empty
272    };
273
274    // Diagnostic: log raw authorizer context shape (debug level only)
275    match request_context {
276        RequestContext::ApiGatewayV1(ctx) => {
277            debug!(
278                authorizer_field_count = ctx.authorizer.fields.len(),
279                authorizer_keys = ?ctx.authorizer.fields.keys().collect::<Vec<_>>(),
280                "V1 REST API authorizer context"
281            );
282        }
283        RequestContext::ApiGatewayV2(ctx) => {
284            if let Some(ref authorizer) = ctx.authorizer {
285                debug!(
286                    authorizer_field_count = authorizer.fields.len(),
287                    authorizer_keys = ?authorizer.fields.keys().collect::<Vec<_>>(),
288                    "V2 HTTP API authorizer context"
289                );
290            } else {
291                debug!("V2 HTTP API: no authorizer present");
292            }
293        }
294        _ => {
295            debug!("Non-API Gateway request context (ALB or other)");
296        }
297    }
298
299    // Extract authorizer fields based on API Gateway version
300    // V1: flat HashMap (may contain "lambda" key or direct fields)
301    // V2: fields in ctx.authorizer.fields (deserialized from "lambda" key by serde)
302    let mut authorizer_fields_map = HashMap::new();
303
304    match request_context {
305        RequestContext::ApiGatewayV2(ctx) => {
306            // API Gateway V2 (HTTP API) format - already HashMap
307            if let Some(ref authorizer) = ctx.authorizer {
308                for (key, value) in &authorizer.fields {
309                    authorizer_fields_map.insert(key.clone(), value.clone());
310                }
311            }
312        }
313        RequestContext::ApiGatewayV1(ctx) => {
314            // API Gateway V1 (REST API) — authorizer fields are deserialized as a
315            // flat HashMap by aws_lambda_events. Two shapes occur:
316            //   1. Nested: { "lambda": { "userId": "...", ... } }
317            //   2. Flat: { "userId": "...", "accountId": "..." }
318            // Try nested "lambda" first, then fall back to flat top-level fields.
319            if let Some(serde_json::Value::Object(auth_map)) = ctx.authorizer.fields.get("lambda") {
320                for (key, value) in auth_map {
321                    authorizer_fields_map.insert(key.clone(), value.clone());
322                }
323            } else {
324                // Flat shape — iterate all fields, skip known API Gateway internals:
325                //   principalId        — required authorizer output, not user context
326                //   integrationLatency — injected by API Gateway
327                //   usageIdentifierKey — API key for usage plans (apiKeySource=AUTHORIZER)
328                for (key, value) in &ctx.authorizer.fields {
329                    if key == "principalId"
330                        || key == "integrationLatency"
331                        || key == "usageIdentifierKey"
332                    {
333                        continue;
334                    }
335                    authorizer_fields_map.insert(key.clone(), value.clone());
336                }
337            }
338        }
339        _ => {} // Other contexts (ALB, etc.) - no authorizer
340    }
341
342    // Convert extracted fields to sanitized headers
343    for (key, value) in authorizer_fields_map {
344        // Sanitize field name for header compatibility
345        let sanitized_key = sanitize_authorizer_field_name(&key);
346
347        // Convert value to string
348        let value_str = match value {
349            serde_json::Value::String(s) => s,
350            other => other.to_string(), // JSON serialize non-strings
351        };
352
353        fields.insert(sanitized_key, value_str);
354    }
355
356    if !fields.is_empty() {
357        debug!(
358            "Extracted {} authorizer fields from Lambda context",
359            fields.len()
360        );
361    }
362
363    fields
364}
365
366/// Extract MCP-specific headers from Lambda request context
367///
368/// Lambda requests may have additional context that needs to be preserved
369/// for proper MCP protocol handling.
370pub fn extract_mcp_headers(req: &LambdaRequest) -> HashMap<String, String> {
371    let mut mcp_headers = HashMap::new();
372
373    // Extract session ID from headers
374    if let Some(session_id) = req.headers().get("mcp-session-id")
375        && let Ok(session_id_str) = session_id.to_str()
376    {
377        mcp_headers.insert("mcp-session-id".to_string(), session_id_str.to_string());
378    }
379
380    // Extract protocol version
381    if let Some(protocol_version) = req.headers().get("mcp-protocol-version")
382        && let Ok(version_str) = protocol_version.to_str()
383    {
384        mcp_headers.insert("mcp-protocol-version".to_string(), version_str.to_string());
385    }
386
387    // Extract Last-Event-ID for SSE resumability
388    if let Some(last_event_id) = req.headers().get("last-event-id")
389        && let Ok(event_id_str) = last_event_id.to_str()
390    {
391        mcp_headers.insert("last-event-id".to_string(), event_id_str.to_string());
392    }
393
394    trace!("Extracted MCP headers: {:?}", mcp_headers);
395    mcp_headers
396}
397
398/// Add MCP-specific headers to Lambda response
399///
400/// Ensures proper MCP protocol headers are included in the response.
401pub fn inject_mcp_headers(resp: &mut LambdaResponse<LambdaBody>, headers: HashMap<String, String>) {
402    for (name, value) in headers {
403        if let (Ok(header_name), Ok(header_value)) = (
404            http::HeaderName::from_bytes(name.as_bytes()),
405            http::HeaderValue::from_str(&value),
406        ) {
407            resp.headers_mut().insert(header_name, header_value);
408            debug!("Injected MCP header: {} = {}", name, value);
409        }
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use http::{HeaderValue, Method, Request, StatusCode};
417    use http_body_util::Full;
418
419    #[test]
420    fn test_lambda_to_hyper_request_conversion() {
421        // Create a test Lambda request with headers and body
422        let mut lambda_req = Request::builder()
423            .method(Method::POST)
424            .uri("/mcp")
425            .body(LambdaBody::Text(
426                r#"{"jsonrpc":"2.0","method":"initialize","id":1}"#.to_string(),
427            ))
428            .unwrap();
429
430        // Add MCP headers
431        let headers = lambda_req.headers_mut();
432        headers.insert("content-type", HeaderValue::from_static("application/json"));
433        headers.insert(
434            "mcp-session-id",
435            HeaderValue::from_static("test-session-123"),
436        );
437        headers.insert(
438            "mcp-protocol-version",
439            HeaderValue::from_static("2025-11-25"),
440        );
441
442        // Test the conversion
443        let hyper_req = lambda_to_hyper_request(lambda_req).unwrap();
444
445        // Verify method and URI are preserved
446        assert_eq!(hyper_req.method(), &Method::POST);
447        assert_eq!(hyper_req.uri().path(), "/mcp");
448
449        // Verify headers are preserved
450        assert_eq!(
451            hyper_req.headers().get("content-type").unwrap(),
452            "application/json"
453        );
454        assert_eq!(
455            hyper_req.headers().get("mcp-session-id").unwrap(),
456            "test-session-123"
457        );
458        assert_eq!(
459            hyper_req.headers().get("mcp-protocol-version").unwrap(),
460            "2025-11-25"
461        );
462    }
463
464    #[test]
465    fn test_lambda_to_hyper_empty_body() {
466        let lambda_req = Request::builder()
467            .method(Method::GET)
468            .uri("/sse")
469            .body(LambdaBody::Empty)
470            .unwrap();
471
472        let hyper_req = lambda_to_hyper_request(lambda_req).unwrap();
473        assert_eq!(hyper_req.method(), &Method::GET);
474        assert_eq!(hyper_req.uri().path(), "/sse");
475    }
476
477    #[test]
478    fn test_lambda_to_hyper_binary_body() {
479        let test_data = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello" in bytes
480        let lambda_req = Request::builder()
481            .method(Method::POST)
482            .uri("/binary")
483            .body(LambdaBody::Binary(test_data.clone()))
484            .unwrap();
485
486        let hyper_req = lambda_to_hyper_request(lambda_req).unwrap();
487        assert_eq!(hyper_req.method(), &Method::POST);
488        assert_eq!(hyper_req.uri().path(), "/binary");
489    }
490
491    #[tokio::test]
492    async fn test_hyper_to_lambda_response_conversion() {
493        // Create a test hyper response
494        let json_body = r#"{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}"#;
495        let full_body = Full::new(Bytes::from(json_body));
496        let boxed_body = full_body.map_err(|never| match never {}).boxed_unsync();
497
498        let hyper_resp = hyper::Response::builder()
499            .status(StatusCode::OK)
500            .header("content-type", "application/json")
501            .header("mcp-session-id", "resp-session-456")
502            .body(boxed_body)
503            .unwrap();
504
505        // Test the conversion
506        let lambda_resp = hyper_to_lambda_response(hyper_resp).await.unwrap();
507
508        // Verify status and headers are preserved
509        assert_eq!(lambda_resp.status(), StatusCode::OK);
510        assert_eq!(
511            lambda_resp.headers().get("content-type").unwrap(),
512            "application/json"
513        );
514        assert_eq!(
515            lambda_resp.headers().get("mcp-session-id").unwrap(),
516            "resp-session-456"
517        );
518
519        // Verify body is converted to text
520        match lambda_resp.body() {
521            LambdaBody::Text(text) => assert_eq!(text, json_body),
522            _ => panic!("Expected text body"),
523        }
524    }
525
526    #[tokio::test]
527    async fn test_hyper_to_lambda_empty_response() {
528        let empty_body = Full::new(Bytes::new());
529        let boxed_body = empty_body.map_err(|never| match never {}).boxed_unsync();
530
531        let hyper_resp = hyper::Response::builder()
532            .status(StatusCode::NO_CONTENT)
533            .body(boxed_body)
534            .unwrap();
535
536        let lambda_resp = hyper_to_lambda_response(hyper_resp).await.unwrap();
537
538        assert_eq!(lambda_resp.status(), StatusCode::NO_CONTENT);
539        match lambda_resp.body() {
540            LambdaBody::Empty => {} // Expected
541            _ => panic!("Expected empty body"),
542        }
543    }
544
545    #[test]
546    fn test_hyper_to_lambda_streaming() {
547        // Create a streaming response
548        let stream_body = Full::new(Bytes::from("data: test\n\n"));
549        let boxed_body = stream_body.map_err(|never| match never {}).boxed_unsync();
550
551        let hyper_resp = hyper::Response::builder()
552            .status(StatusCode::OK)
553            .header("content-type", "text/event-stream")
554            .header("cache-control", "no-cache")
555            .body(boxed_body)
556            .unwrap();
557
558        // Test streaming conversion (should preserve body as-is)
559        let lambda_resp = hyper_to_lambda_streaming(hyper_resp);
560
561        assert_eq!(lambda_resp.status(), StatusCode::OK);
562        assert_eq!(
563            lambda_resp.headers().get("content-type").unwrap(),
564            "text/event-stream"
565        );
566        assert_eq!(
567            lambda_resp.headers().get("cache-control").unwrap(),
568            "no-cache"
569        );
570        // Body should be preserved as UnifiedMcpBody for streaming
571    }
572
573    #[tokio::test]
574    async fn test_mcp_headers_extraction() {
575        use http::{HeaderValue, Request};
576
577        // Create a test request with MCP headers
578        let mut request = Request::builder()
579            .method("POST")
580            .uri("/mcp")
581            .body(LambdaBody::Empty)
582            .unwrap();
583
584        let headers = request.headers_mut();
585        headers.insert("mcp-session-id", HeaderValue::from_static("sess-123"));
586        headers.insert(
587            "mcp-protocol-version",
588            HeaderValue::from_static("2025-11-25"),
589        );
590        headers.insert("last-event-id", HeaderValue::from_static("event-456"));
591
592        let mcp_headers = extract_mcp_headers(&request);
593
594        assert_eq!(
595            mcp_headers.get("mcp-session-id"),
596            Some(&"sess-123".to_string())
597        );
598        assert_eq!(
599            mcp_headers.get("mcp-protocol-version"),
600            Some(&"2025-11-25".to_string())
601        );
602        assert_eq!(
603            mcp_headers.get("last-event-id"),
604            Some(&"event-456".to_string())
605        );
606    }
607
608    #[tokio::test]
609    async fn test_mcp_headers_injection() {
610        use lambda_http::Body;
611
612        let mut lambda_resp = LambdaResponse::builder()
613            .status(200)
614            .body(Body::Empty)
615            .unwrap();
616
617        let mut headers = HashMap::new();
618        headers.insert("mcp-session-id".to_string(), "sess-789".to_string());
619        headers.insert("mcp-protocol-version".to_string(), "2025-11-25".to_string());
620
621        inject_mcp_headers(&mut lambda_resp, headers);
622
623        assert_eq!(
624            lambda_resp.headers().get("mcp-session-id").unwrap(),
625            "sess-789"
626        );
627        assert_eq!(
628            lambda_resp.headers().get("mcp-protocol-version").unwrap(),
629            "2025-11-25"
630        );
631    }
632
633    // Authorizer context tests
634    mod authorizer_tests {
635        use super::*;
636
637        #[test]
638        fn test_sanitize_field_name_camelcase() {
639            // camelCase → snake_case conversion
640            assert_eq!(sanitize_authorizer_field_name("accountId"), "account_id");
641            assert_eq!(sanitize_authorizer_field_name("entityType"), "entity_type");
642            assert_eq!(sanitize_authorizer_field_name("deviceId"), "device_id");
643            assert_eq!(sanitize_authorizer_field_name("userId"), "user_id");
644            assert_eq!(sanitize_authorizer_field_name("tenantId"), "tenant_id");
645            assert_eq!(
646                sanitize_authorizer_field_name("customClaim"),
647                "custom_claim"
648            );
649        }
650
651        #[test]
652        fn test_sanitize_field_name_snake_case() {
653            // Already snake_case - should remain unchanged
654            assert_eq!(sanitize_authorizer_field_name("device_id"), "device_id");
655            assert_eq!(sanitize_authorizer_field_name("user_name"), "user_name");
656            assert_eq!(sanitize_authorizer_field_name("tenant_id"), "tenant_id");
657        }
658
659        #[test]
660        fn test_sanitize_field_name_acronyms() {
661            // Acronyms: treated as a single unit, underscore before transition to lowercase
662            assert_eq!(sanitize_authorizer_field_name("APIKey"), "api_key");
663            assert_eq!(
664                sanitize_authorizer_field_name("HTTPSEnabled"),
665                "https_enabled"
666            );
667            assert_eq!(sanitize_authorizer_field_name("XMLParser"), "xml_parser");
668        }
669
670        #[test]
671        fn test_sanitize_field_name_with_numbers() {
672            // Numbers should be preserved
673            assert_eq!(sanitize_authorizer_field_name("userId123"), "user_id123");
674            assert_eq!(sanitize_authorizer_field_name("device2Id"), "device2_id");
675        }
676
677        #[test]
678        fn test_sanitize_field_name_special_chars() {
679            assert_eq!(sanitize_authorizer_field_name("user@email"), "user-email");
680            assert_eq!(sanitize_authorizer_field_name("test.field"), "test-field");
681            assert_eq!(sanitize_authorizer_field_name("a/b/c"), "a-b-c");
682        }
683
684        #[test]
685        fn test_sanitize_field_name_unicode() {
686            // Unicode characters get replaced with dashes (one dash per character)
687            assert_eq!(sanitize_authorizer_field_name("用户"), "--");
688        }
689
690        #[test]
691        fn test_extract_authorizer_no_context() {
692            // Request with no extensions
693            let lambda_req = Request::builder()
694                .method(Method::POST)
695                .uri("/mcp")
696                .body(LambdaBody::Empty)
697                .unwrap();
698
699            let fields = extract_authorizer_context(&lambda_req);
700            assert!(fields.is_empty());
701        }
702
703        #[test]
704        fn test_lambda_to_hyper_without_authorizer() {
705            // Request without authorizer should work normally
706            let lambda_req = Request::builder()
707                .method(Method::POST)
708                .uri("/mcp")
709                .header("content-type", "application/json")
710                .body(LambdaBody::Empty)
711                .unwrap();
712
713            let hyper_req = lambda_to_hyper_request(lambda_req).unwrap();
714
715            // Should succeed, no authorizer headers
716            assert!(hyper_req.headers().get("x-authorizer-account_id").is_none());
717            assert_eq!(
718                hyper_req.headers().get("content-type").unwrap(),
719                "application/json"
720            );
721        }
722
723        /// Helper: build a LambdaRequest with a RequestContext inserted into extensions
724        fn request_with_context(ctx: lambda_http::request::RequestContext) -> LambdaRequest {
725            let mut req = Request::builder()
726                .method(Method::POST)
727                .uri("/mcp")
728                .body(LambdaBody::Empty)
729                .unwrap();
730            req.extensions_mut().insert(ctx);
731            req
732        }
733
734        #[test]
735        fn test_extract_authorizer_v1_top_level_fields() {
736            // V1 REST API where authorizer returns flat fields (no "lambda" wrapper).
737            // This is the shape seen with proxy integration authorizers that return
738            // context directly, e.g. { "userId": "user-123", "tenantId": "tenant-456" }
739            use aws_lambda_events::apigw::{
740                ApiGatewayProxyRequestContext, ApiGatewayRequestAuthorizer,
741            };
742
743            let mut authorizer = ApiGatewayRequestAuthorizer::default();
744            authorizer
745                .fields
746                .insert("userId".to_string(), serde_json::json!("user-123"));
747            authorizer
748                .fields
749                .insert("tenantId".to_string(), serde_json::json!("tenant-456"));
750            authorizer
751                .fields
752                .insert("role".to_string(), serde_json::json!("admin"));
753
754            let mut v1_ctx = ApiGatewayProxyRequestContext::default();
755            v1_ctx.authorizer = authorizer;
756
757            let req =
758                request_with_context(lambda_http::request::RequestContext::ApiGatewayV1(v1_ctx));
759            let fields = extract_authorizer_context(&req);
760
761            assert_eq!(fields.get("user_id"), Some(&"user-123".to_string()));
762            assert_eq!(fields.get("tenant_id"), Some(&"tenant-456".to_string()));
763            assert_eq!(fields.get("role"), Some(&"admin".to_string()));
764        }
765
766        #[test]
767        fn test_extract_authorizer_v1_nested_lambda() {
768            // V1 REST API where authorizer context is nested under "lambda" key.
769            // Shape: { "lambda": { "userId": "user-123", ... } }
770            use aws_lambda_events::apigw::{
771                ApiGatewayProxyRequestContext, ApiGatewayRequestAuthorizer,
772            };
773
774            let mut authorizer = ApiGatewayRequestAuthorizer::default();
775            authorizer.fields.insert(
776                "lambda".to_string(),
777                serde_json::json!({
778                    "userId": "user-123",
779                    "tenantId": "tenant-456"
780                }),
781            );
782
783            let mut v1_ctx = ApiGatewayProxyRequestContext::default();
784            v1_ctx.authorizer = authorizer;
785
786            let req =
787                request_with_context(lambda_http::request::RequestContext::ApiGatewayV1(v1_ctx));
788            let fields = extract_authorizer_context(&req);
789
790            assert_eq!(fields.get("user_id"), Some(&"user-123".to_string()));
791            assert_eq!(fields.get("tenant_id"), Some(&"tenant-456".to_string()));
792        }
793
794        #[test]
795        fn test_extract_authorizer_v1_skips_internal_fields() {
796            // Verify API Gateway internal fields are excluded from extraction
797            use aws_lambda_events::apigw::{
798                ApiGatewayProxyRequestContext, ApiGatewayRequestAuthorizer,
799            };
800
801            let mut authorizer = ApiGatewayRequestAuthorizer::default();
802            authorizer
803                .fields
804                .insert("userId".to_string(), serde_json::json!("user-123"));
805            authorizer.fields.insert(
806                "principalId".to_string(),
807                serde_json::json!("principal-abc"),
808            );
809            authorizer
810                .fields
811                .insert("integrationLatency".to_string(), serde_json::json!(42));
812            authorizer.fields.insert(
813                "usageIdentifierKey".to_string(),
814                serde_json::json!("api-key-xyz"),
815            );
816
817            let mut v1_ctx = ApiGatewayProxyRequestContext::default();
818            v1_ctx.authorizer = authorizer;
819
820            let req =
821                request_with_context(lambda_http::request::RequestContext::ApiGatewayV1(v1_ctx));
822            let fields = extract_authorizer_context(&req);
823
824            assert_eq!(fields.get("user_id"), Some(&"user-123".to_string()));
825            assert!(
826                !fields.contains_key("principal_id"),
827                "principalId should be skipped"
828            );
829            assert!(
830                !fields.contains_key("integration_latency"),
831                "integrationLatency should be skipped"
832            );
833            assert!(
834                !fields.contains_key("usage_identifier_key"),
835                "usageIdentifierKey should be skipped"
836            );
837        }
838
839        #[test]
840        fn test_extract_authorizer_v1_non_string_values() {
841            // Verify numeric and boolean values are JSON-serialized to strings
842            use aws_lambda_events::apigw::{
843                ApiGatewayProxyRequestContext, ApiGatewayRequestAuthorizer,
844            };
845
846            let mut authorizer = ApiGatewayRequestAuthorizer::default();
847            authorizer
848                .fields
849                .insert("maxAge".to_string(), serde_json::json!(3600));
850            authorizer
851                .fields
852                .insert("isAdmin".to_string(), serde_json::json!(true));
853
854            let mut v1_ctx = ApiGatewayProxyRequestContext::default();
855            v1_ctx.authorizer = authorizer;
856
857            let req =
858                request_with_context(lambda_http::request::RequestContext::ApiGatewayV1(v1_ctx));
859            let fields = extract_authorizer_context(&req);
860
861            assert_eq!(fields.get("max_age"), Some(&"3600".to_string()));
862            assert_eq!(fields.get("is_admin"), Some(&"true".to_string()));
863        }
864
865        #[test]
866        fn test_extract_authorizer_v1_empty() {
867            // Verify empty authorizer returns empty HashMap
868            use aws_lambda_events::apigw::ApiGatewayProxyRequestContext;
869
870            let v1_ctx = ApiGatewayProxyRequestContext::default();
871
872            let req =
873                request_with_context(lambda_http::request::RequestContext::ApiGatewayV1(v1_ctx));
874            let fields = extract_authorizer_context(&req);
875
876            assert!(fields.is_empty());
877        }
878
879        #[test]
880        fn test_extract_authorizer_v2_lambda_fields() {
881            // V2 HTTP API — authorizer.fields are already deserialized from "lambda" key
882            use aws_lambda_events::apigw::{
883                ApiGatewayRequestAuthorizer, ApiGatewayV2httpRequestContext,
884            };
885
886            let mut authorizer = ApiGatewayRequestAuthorizer::default();
887            authorizer
888                .fields
889                .insert("userId".to_string(), serde_json::json!("user-v2"));
890            authorizer
891                .fields
892                .insert("scope".to_string(), serde_json::json!("read write"));
893
894            let mut v2_ctx = ApiGatewayV2httpRequestContext::default();
895            v2_ctx.authorizer = Some(authorizer);
896
897            let req =
898                request_with_context(lambda_http::request::RequestContext::ApiGatewayV2(v2_ctx));
899            let fields = extract_authorizer_context(&req);
900
901            assert_eq!(fields.get("user_id"), Some(&"user-v2".to_string()));
902            assert_eq!(fields.get("scope"), Some(&"read write".to_string()));
903        }
904    }
905}