diff --git a/Cargo.toml b/Cargo.toml index 54716260..0f1d93c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ lambda_http = { version = "1.3.0", default-features = false, features = [ "apigw_http", "apigw_rest", "alb", + "vpc_lattice", "pass_through", "tracing", "concurrency-tokio" diff --git a/README.md b/README.md index 67c2f2fb..1d8d1d80 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The same docker image can run on AWS Lambda, Amazon EC2, AWS Fargate, and local - Run web applications on AWS Lambda - Supports Amazon API Gateway Rest API and Http API endpoints, Lambda Function URLs, and Application Load Balancer +- Supports VPC Lattice Lambda event structure version V2 with target-group configuration; V1 events are treated as non-HTTP events and passed through to `AWS_LWA_PASS_THROUGH_PATH` (default `/events`) - Supports Lambda managed runtimes, custom runtimes and docker OCI images - Supports Lambda Managed Instances for multi-concurrent request handling - Supports Lambda SnapStart with before-checkpoint and after-restore hooks diff --git a/docs/guide/src/features/request-context.md b/docs/guide/src/features/request-context.md index 3cee18af..a171f2dc 100644 --- a/docs/guide/src/features/request-context.md +++ b/docs/guide/src/features/request-context.md @@ -4,7 +4,7 @@ Lambda Web Adapter forwards API Gateway request context and Lambda invocation co ## Request Context -API Gateway sends metadata (requestId, requestTime, apiId, identity, authorizer) for each request. This is forwarded in the `x-amzn-request-context` header as a JSON string. +API Gateway sends metadata (requestId, requestTime, apiId, identity, authorizer) for each request. VPC Lattice event structure version V2 also sends request context metadata. These contexts are forwarded in the `x-amzn-request-context` header as a JSON string. The identity and authorizer fields are particularly useful for client authorization. @@ -19,6 +19,8 @@ app.get('/', (req, res) => { See the [API Gateway docs](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format) for the full request context schema. +For VPC Lattice, set the target group's Lambda event structure version to V2. The adapter supports the V2 payload format and forwards its request context. V1 events are treated as non-HTTP events and passed through to `AWS_LWA_PASS_THROUGH_PATH`, which defaults to `/events`. + ## Lambda Context The Lambda invocation context (function name, memory, timeout, request ID, etc.) is forwarded in the `x-amzn-lambda-context` header as a JSON string. diff --git a/docs/guide/src/introduction.md b/docs/guide/src/introduction.md index a8feb842..328aa90d 100644 --- a/docs/guide/src/introduction.md +++ b/docs/guide/src/introduction.md @@ -8,7 +8,7 @@ AWS Lambda Web Adapter lets developers build web apps with familiar frameworks ( ## Key Features -- Supports Amazon API Gateway (REST & HTTP API), Lambda Function URLs, and Application Load Balancer +- Supports Amazon API Gateway (REST & HTTP API), VPC Lattice Lambda event structure version V2, Lambda Function URLs, and Application Load Balancer - Works with Lambda managed runtimes, custom runtimes, and Docker OCI images - Supports Lambda Managed Instances for multi-concurrent request handling - Framework and language agnostic — no new code dependencies @@ -17,7 +17,7 @@ AWS Lambda Web Adapter lets developers build web apps with familiar frameworks ( - Response payload compression (gzip/brotli) - Response streaming - Multi-tenancy via tenant ID propagation -- Non-HTTP event trigger support (SQS, SNS, S3, DynamoDB, Kinesis, Kafka, EventBridge, Bedrock Agents) +- Non-HTTP event trigger support (SQS, SNS, S3, DynamoDB, Kinesis, Kafka, EventBridge, Bedrock Agents, and VPC Lattice event structure version V1) via pass-through ## How It Works diff --git a/docs/guide/src/reference/architecture.md b/docs/guide/src/reference/architecture.md index a3ca3e02..94360cc5 100644 --- a/docs/guide/src/reference/architecture.md +++ b/docs/guide/src/reference/architecture.md @@ -8,9 +8,10 @@ AWS Lambda Web Adapter is a Lambda Extension that bridges the gap between Lambda - Amazon API Gateway REST API - Amazon API Gateway HTTP API (v2 event format) +- VPC Lattice (Lambda event structure version V2) - Application Load Balancer (ALB) - Lambda Function URLs -- Non-HTTP triggers (SQS, SNS, S3, DynamoDB, Kinesis, Kafka, EventBridge, Bedrock Agents) via pass-through +- Non-HTTP triggers (SQS, SNS, S3, DynamoDB, Kinesis, Kafka, EventBridge, Bedrock Agents, and VPC Lattice event structure version V1) via pass-through ## Request Flow diff --git a/tests/integ_tests/main.rs b/tests/integ_tests/main.rs index ba0f1c5d..671f3fba 100644 --- a/tests/integ_tests/main.rs +++ b/tests/integ_tests/main.rs @@ -13,8 +13,8 @@ use httpmock::{ Method::{DELETE, GET, POST, PUT}, MockServer, }; -use lambda_http::Body; -use lambda_http::Context; +use lambda_http::request::RequestContext; +use lambda_http::{Body, Context, RequestExt}; use lambda_web_adapter::{Adapter, AdapterOptions, LambdaInvokeMode, Protocol}; use tower::{Service, ServiceBuilder}; @@ -661,6 +661,178 @@ async fn test_http_context_headers() { assert_eq!("OK", body_to_string(response).await); } +#[tokio::test] +async fn test_non_http_event_routes_to_configured_pass_through_path() { + let app_server = MockServer::start(); + let event = pass_through_bedrock_agent_event(); + let expected_body = event.clone(); + + let endpoint = app_server.mock(move |when, then| { + when.method(POST) + .path("/lambda-events") + .header("content-type", "application/json") + .body(expected_body); + then.status(200).body("pass-through"); + }); + + let mut adapter = Adapter::new(&AdapterOptions { + host: app_server.host(), + port: app_server.port().to_string(), + readiness_check_port: app_server.port().to_string(), + readiness_check_path: "/healthcheck".to_string(), + pass_through_path: "/lambda-events".to_string(), + ..Default::default() + }) + .expect("Failed to create adapter"); + let mut request = lambda_http::request::from_str(&event).expect("Failed to deserialize event"); + + assert!(matches!(request.request_context(), RequestContext::PassThrough)); + add_lambda_context_to_request(&mut request); + + let response = adapter.call(request).await.expect("Request failed"); + + endpoint.assert(); + assert_eq!(200, response.status()); + assert_eq!("pass-through", body_to_string(response).await); +} + +#[test] +fn test_http_event_request_context_classification() { + let sqs_event = include_str!("../../examples/sqs-expressjs/events/sqs.json"); + let sqs_request = lambda_http::request::from_str(sqs_event).expect("Failed to deserialize SQS event"); + assert!(matches!(sqs_request.request_context(), RequestContext::PassThrough)); + + let api_gateway_v1_event = include_str!("../../examples/fastapi/events/event.json"); + let api_gateway_v1_request = + lambda_http::request::from_str(&api_gateway_v1_event).expect("Failed to deserialize API Gateway V1 event"); + assert!(matches!( + api_gateway_v1_request.request_context(), + RequestContext::ApiGatewayV1(_) + )); + + let alb_event = json!({ + "httpMethod": "GET", + "path": "/health", + "multiValueHeaders": {"host": ["example.com"]}, + "multiValueQueryStringParameters": {"state": ["prod"]}, + "requestContext": { + "elb": { + "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/example/abcdef" + } + }, + "isBase64Encoded": false + }) + .to_string(); + let alb_request = lambda_http::request::from_str(&alb_event).expect("Failed to deserialize ALB event"); + assert!(matches!(alb_request.request_context(), RequestContext::Alb(_))); + + let api_gateway_v2_event = json!({ + "version": "2.0", + "routeKey": "$default", + "rawPath": "/health", + "requestContext": { + "requestId": "abcdef", + "stage": "$default", + "http": { + "method": "GET", + "path": "/health", + "protocol": "HTTP/1.1", + "sourceIp": "127.0.0.1", + "userAgent": "curl/8.0.0" + } + }, + "isBase64Encoded": false + }) + .to_string(); + let api_gateway_v2_request = + lambda_http::request::from_str(&api_gateway_v2_event).expect("Failed to deserialize API Gateway V2 event"); + assert!(matches!( + api_gateway_v2_request.request_context(), + RequestContext::ApiGatewayV2(_) + )); +} + +#[tokio::test] +async fn test_vpc_lattice_v2_event_routes_with_path_query_and_context() { + let app_server = MockServer::start(); + let event = vpc_lattice_v2_event(); + + let expected_request_context = json!({ + "serviceNetworkArn": VPC_LATTICE_SERVICE_NETWORK_ARN, + "serviceArn": VPC_LATTICE_SERVICE_ARN, + "targetGroupArn": VPC_LATTICE_TARGET_GROUP_ARN, + "identity": { + "sourceVpcArn": "arn:aws:ec2:ap-southeast-2:123456789012:vpc/vpc-0b8276c84697e7339", + "type": "AWS_IAM", + "principal": "arn:aws:iam::123456789012:role/service-role/HealthChecker", + "principalOrgID": "o-50dc6c495c0c9188" + }, + "region": "ap-southeast-2", + "timeEpoch": "1724875399456789" + }); + + let endpoint = app_server.mock(move |when, then| { + when.method(POST) + .path("/health") + .query_param("state", "prod") + .query_param_count("mode", "fast", 1) + .query_param_count("mode", "turbo", 1) + .json_body(serde_json::from_str::(VPC_LATTICE_BODY).expect("valid JSON body")) + .is_true(move |req| { + let headers = req.headers(); + let Some(request_context) = headers + .get("x-amzn-request-context") + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + + let Ok(request_context) = serde_json::from_str::(request_context) else { + return false; + }; + + expected_request_context + .as_object() + .into_iter() + .flatten() + .all(|(key, expected)| match (key.as_str(), expected) { + ("identity", expected_identity) => expected_identity + .as_object() + .into_iter() + .flatten() + .all(|(key, expected)| request_context["identity"][key] == *expected), + (key, expected) => request_context[key] == *expected, + }) + }); + then.status(200).body("vpc lattice"); + }); + + let mut adapter = Adapter::new(&AdapterOptions { + host: app_server.host(), + port: app_server.port().to_string(), + readiness_check_port: app_server.port().to_string(), + readiness_check_path: "/healthcheck".to_string(), + ..Default::default() + }) + .expect("Failed to create adapter"); + + let mut request = lambda_http::request::from_str(&event).expect("Failed to deserialize VPC Lattice event"); + + match request.request_context() { + RequestContext::VpcLattice(context) => { + assert_eq!(VPC_LATTICE_TARGET_GROUP_ARN, context.target_group_arn); + } + other => panic!("unexpected request context: {other:?}"), + } + + add_lambda_context_to_request(&mut request); + let response = adapter.call(request).await.expect("Request failed"); + + endpoint.assert(); + assert_eq!(200, response.status()); + assert_eq!("vpc lattice", body_to_string(response).await); +} + #[tokio::test] async fn test_http_content_encoding_suffix() { // Start app server @@ -1217,6 +1389,89 @@ fn add_lambda_context_to_request(request: &mut Request) { request.extensions_mut().insert(context); } +fn pass_through_bedrock_agent_event() -> String { + json!({ + "messageVersion": "1.0", + "agent": { + "name": "AgentName", + "id": "AgentID", + "alias": "AgentAlias", + "version": "AgentVersion" + }, + "inputText": "InputText", + "sessionId": "SessionID", + "actionGroup": "ActionGroup", + "apiPath": "/api/path", + "httpMethod": "POST", + "parameters": [ + { + "name": "param1", + "type": "string", + "value": "value1" + } + ], + "requestBody": { + "content": { + "application/json": { + "properties": [ + { + "name": "prop1", + "type": "string", + "value": "value1" + } + ] + } + } + }, + "sessionAttributes": { + "attr1": "value1" + }, + "promptSessionAttributes": { + "promptAttr1": "value1" + } + }) + .to_string() +} + +const VPC_LATTICE_SERVICE_NETWORK_ARN: &str = + "arn:aws:vpc-lattice:ap-southeast-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a"; +const VPC_LATTICE_SERVICE_ARN: &str = "arn:aws:vpc-lattice:ap-southeast-2:123456789012:service/svc-0a40eebed65f8d69c"; +const VPC_LATTICE_TARGET_GROUP_ARN: &str = + "arn:aws:vpc-lattice:ap-southeast-2:123456789012:targetgroup/tg-6d0ecf831eec9f09"; +const VPC_LATTICE_BODY: &str = r#"{"message":"hello from vpc lattice"}"#; + +fn vpc_lattice_v2_event() -> String { + json!({ + "version": "2.0", + "path": "/health", + "method": "POST", + "headers": { + "accept": ["*/*"], + "user-agent": ["curl/7.68.0"] + }, + "queryStringParameters": { + "state": ["prod"], + "mode": ["fast", "turbo"] + }, + "body": VPC_LATTICE_BODY, + "isBase64Encoded": false, + "requestContext": { + "serviceNetworkArn": VPC_LATTICE_SERVICE_NETWORK_ARN, + "serviceArn": VPC_LATTICE_SERVICE_ARN, + "targetGroupArn": VPC_LATTICE_TARGET_GROUP_ARN, + "identity": { + "sourceVpcArn": "arn:aws:ec2:ap-southeast-2:123456789012:vpc/vpc-0b8276c84697e7339", + "type": "AWS_IAM", + "principal": "arn:aws:iam::123456789012:role/service-role/HealthChecker", + "principalOrgID": "o-50dc6c495c0c9188" + }, + "region": "ap-southeast-2", + "timeEpoch": "1724875399456789" + } + }) + .to_string() +} + #[tokio::test] async fn test_concurrent_request_forwarding() { let app_server = MockServer::start();