Repository navigation
feat: enable VPC Lattice support #829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7d1a36d
a57e17f
6051df4
d829361
145a3d3
19156ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ lambda_http = { version = "1.3.0", default-features = false, features = [ | |
| "apigw_http", | ||
| "apigw_rest", | ||
| "alb", | ||
| "vpc_lattice", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] Enabling Concretely stale/incomplete after this change:
The payload-format point matters behaviorally, not just editorially: the PR description and the test fixture ( if matches!(request_context, RequestContext::PassThrough) && parts.method == Method::POST {
path = self.pass_through_path.as_str();
}That means a misconfigured target group silently POSTs the raw event to |
||
| "pass_through", | ||
| "tracing", | ||
| "concurrency-tokio" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] The regression guard for the classification change covers a single non-HTTP payload shape, which leaves most of the documented pass-through surface unguarded. This PR inserts a new variant into Similarly, Suggested additions, following the pattern already established in the new test: let sqs_event = json!({
"Records": [{
"messageId": "059f36b4-87a3-44ab-83d2-661975830a7d",
"receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a",
"body": "Test message.",
"eventSource": "aws:sqs",
"awsRegion": "us-east-1"
}]
})
.to_string();
let sqs_request = lambda_http::request::from_str(&sqs_event).expect("Failed to deserialize SQS event");
assert!(matches!(sqs_request.request_context(), RequestContext::PassThrough));Without these, a future variant reordering or event-struct loosening in Cargo.lock was reviewed as a lock file only (version/checksum bumps for aws_lambda_events, lambda_http, lambda_runtime, lambda_runtime_api_client); no findings. I did not evaluate whether the vpc_lattice feature or the pinned versions resolve correctly, since the crate sources are not available in this workspace and the PR notes cargo check --locked could not complete — worth confirming in CI before merge, given the 244 lines of new test code have not been compiled. |
||
| 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() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] The classification guard misses the payload shapes most at risk from inserting a variant into an untagged enum. Context that raises the stakes: every other integration test builds its event as a typed value ( The ALB fixture here covers only the multi-value-headers-disabled shape: "headers": {"host": "example.com"},An ALB target group with multi-value headers enabled sends Two cheap improvements:
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(_)
));That also removes a maintenance trap: a minimal fixture pins the variant only for the fields it happens to include, so it can keep passing while a real event with a conflicting field type classifies differently. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] The PR documents VPC Lattice V1 behavior in four places but never tests it, and V1 is the one classification outcome the new variant is most likely to change. The diff adds this contract to
The consequence if it does not land on The existing test is the natural place for it: let vpc_lattice_v1_event = json!({
"raw_path": "/health",
"method": "GET",
"headers": {"accept": "*/*"},
"query_string_parameters": {"state": "prod"},
"body": VPC_LATTICE_BODY,
"is_base64_encoded": false
})
.to_string();
let vpc_lattice_v1_request =
lambda_http::request::from_str(&vpc_lattice_v1_event).expect("Failed to deserialize VPC Lattice V1 event");
assert!(matches!(
vpc_lattice_v1_request.request_context(),
RequestContext::PassThrough
)); |
||
| let sqs_event = include_str!("../../examples/sqs-expressjs/events/sqs.json"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [GENERAL] Pulling test fixtures out of The two new let sqs_event = include_str!("../../examples/sqs-expressjs/events/sqs.json");
// ...
let api_gateway_v1_event = include_str!("../../examples/fastapi/events/event.json");Both paths-ignore:
- "docs/**"
- "examples/**"So a PR that renames, moves, or edits either JSON file runs neither The example event files also exist to be passed to Copying the payloads under |
||
| 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::<serde_json::Value>(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::<serde_json::Value>(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<Body>) { | |
| 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(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[BUG] Adding another HTTP event variant alongside
pass_throughchanges event classification, and nothing in this PR verifies that non-HTTP events still behave as before.pass_throughis the fallback variant oflambda_http's event enum, and it is the adapter's only mechanism for non-HTTP triggers. Insrc/lib.rs:Every SQS, SNS, S3, DynamoDB, EventBridge, and Bedrock Agent payload reaches the app only because it deserializes into
PassThrough(documented indocs/guide/src/features/non-http-events.md, exercised byexamples/sqs-expressjsandexamples/bedrock-agent-fastapi). Enabling one more variant necessarily shrinks the set of payloads that reach that fallback. VPC Lattice payloads are shaped as loosely typedmethod/raw_path/headers/query_string_parameters/body/is_base64_encodedfields, so if those fields deserialize with defaults, an unrelated event JSON can match the Lattice variant instead of falling through. The failure is silent: the event would be forwarded as a GET to/with an empty body rather than POSTed toAWS_LWA_PASS_THROUGH_PATH, so a pass-through handler would simply stop receiving messages.Two things are worth adding before merge:
tests/integ_tests/common/mod.rsonly builds ALB events:x-amzn-request-contextheader. There is currently no coverage that the newly enabled variant works end to end throughfetch_response, which derives the path fromraw_http_path()and serializes the context into a header.This matters more than usual here because the PR description notes
cargo check --lockedcould not complete in the author's environment, so neither compilation nor the existing test suite has been run against the change.