From 2dffdbe5577e7aa9450699cad1de5959cec5ee80 Mon Sep 17 00:00:00 2001 From: andrewpatto Date: Wed, 8 Apr 2026 14:02:03 +1000 Subject: [PATCH 1/3] Added code and tests for HTTP handler for VPC lattice --- lambda-events/src/event/vpc_lattice/v2.rs | 7 +- lambda-http/Cargo.toml | 5 +- lambda-http/src/deserializer.rs | 21 ++ lambda-http/src/lib.rs | 5 +- lambda-http/src/request.rs | 245 +++++++++++++++++- lambda-http/src/response.rs | 77 +++++- .../tests/data/vpc_lattice_v2_request.json | 30 +++ .../data/vpc_lattice_v2_request_base64.json | 31 +++ .../vpc_lattice_v2_request_encoded_query.json | 21 ++ 9 files changed, 425 insertions(+), 17 deletions(-) create mode 100644 lambda-http/tests/data/vpc_lattice_v2_request.json create mode 100644 lambda-http/tests/data/vpc_lattice_v2_request_base64.json create mode 100644 lambda-http/tests/data/vpc_lattice_v2_request_encoded_query.json diff --git a/lambda-events/src/event/vpc_lattice/v2.rs b/lambda-events/src/event/vpc_lattice/v2.rs index eafed8884..1865a8644 100644 --- a/lambda-events/src/event/vpc_lattice/v2.rs +++ b/lambda-events/src/event/vpc_lattice/v2.rs @@ -1,7 +1,4 @@ -use crate::{ - custom_serde::{deserialize_headers, deserialize_nullish, http_method, serialize_multi_value_headers}, - encodings::Body, -}; +use crate::custom_serde::{deserialize_headers, deserialize_nullish, http_method, serialize_multi_value_headers}; #[cfg(feature = "builders")] use bon::Builder; use http::{HeaderMap, Method}; @@ -47,7 +44,7 @@ pub struct VpcLatticeRequestV2 { /// The request body #[serde(default)] - pub body: Option, + pub body: Option, /// Whether the body is base64 encoded #[serde(default, deserialize_with = "deserialize_nullish")] diff --git a/lambda-http/Cargo.toml b/lambda-http/Cargo.toml index 1f378a5bc..ae97388a9 100644 --- a/lambda-http/Cargo.toml +++ b/lambda-http/Cargo.toml @@ -17,11 +17,12 @@ categories = ["web-programming::http-server"] readme = "README.md" [features] -default = ["apigw_rest", "apigw_http", "apigw_websockets", "alb", "tracing"] +default = ["apigw_rest", "apigw_http", "apigw_websockets", "alb", "vpc_lattice", "tracing"] apigw_rest = [] apigw_http = [] apigw_websockets = [] alb = [] +vpc_lattice = [] pass_through = [] catch-all-fields = ["aws_lambda_events/catch-all-fields"] tracing = ["lambda_runtime/tracing"] # enables access to the Tracing utilities @@ -53,7 +54,7 @@ url = "2.2" path = "../lambda-events" version = "1.1" default-features = false -features = ["alb", "apigw"] +features = ["alb", "apigw", "vpc_lattice"] [dev-dependencies] axum-core = "0.5.4" diff --git a/lambda-http/src/deserializer.rs b/lambda-http/src/deserializer.rs index e0da5e0e1..0de29de13 100644 --- a/lambda-http/src/deserializer.rs +++ b/lambda-http/src/deserializer.rs @@ -7,6 +7,9 @@ use aws_lambda_events::apigw::ApiGatewayProxyRequest; use aws_lambda_events::apigw::ApiGatewayV2httpRequest; #[cfg(feature = "apigw_websockets")] use aws_lambda_events::apigw::ApiGatewayWebsocketProxyRequest; +#[cfg(feature = "vpc_lattice")] +use aws_lambda_events::vpc_lattice::VpcLatticeRequestV2; + use serde::{de::Error, Deserialize}; use serde_json::value::RawValue; @@ -39,6 +42,10 @@ impl<'de> Deserialize<'de> for LambdaRequest { if let Ok(res) = serde_json::from_str::(data) { return Ok(LambdaRequest::WebSocket(res)); } + #[cfg(feature = "vpc_lattice")] + if let Ok(res) = serde_json::from_str::(data) { + return Ok(LambdaRequest::VpcLatticeV2(res)); + } #[cfg(feature = "pass_through")] if PASS_THROUGH_ENABLED { return Ok(LambdaRequest::PassThrough(data.to_string())); @@ -136,6 +143,20 @@ mod tests { } } + #[test] + fn test_deserialize_vpc_lattice() { + let data = + include_bytes!("../../lambda-events/src/fixtures/example-vpc-lattice-v2-request.json"); + + let req: LambdaRequest = serde_json::from_slice(data).expect("failed to deserialize vpc lattice data"); + match req { + LambdaRequest::VpcLatticeV2(req) => { + assert_eq!("arn:aws:vpc-lattice:ap-southeast-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", req.request_context.target_group_arn); + } + other => panic!("unexpected request variant: {other:?}"), + } + } + #[test] #[cfg(feature = "pass_through")] fn test_deserialize_bedrock_agent() { diff --git a/lambda-http/src/lib.rs b/lambda-http/src/lib.rs index bf0c2f9b5..a1e851259 100644 --- a/lambda-http/src/lib.rs +++ b/lambda-http/src/lib.rs @@ -2,7 +2,10 @@ #![cfg_attr(docsrs, feature(doc_cfg))] //#![deny(warnings)] //! Enriches the `lambda` crate with [`http`](https://github.com/hyperium/http) -//! types targeting AWS [ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html), [API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html) REST and HTTP API lambda integrations. +//! types targeting AWS +//! [ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html), +//! [API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html), +//! [VPC Lattice](https://docs.aws.amazon.com/vpc-lattice/latest/ug/lambda-functions.html) REST and HTTP API lambda integrations. //! //! This crate abstracts over all of these trigger events using standard [`http`](https://github.com/hyperium/http) types minimizing the mental overhead //! of understanding the nuances and variation between trigger details allowing you to focus more on your application while also giving you to the maximum flexibility to diff --git a/lambda-http/src/request.rs b/lambda-http/src/request.rs index 450ba94aa..9526591d9 100644 --- a/lambda-http/src/request.rs +++ b/lambda-http/src/request.rs @@ -1,6 +1,6 @@ -//! ALB and API Gateway request adaptations +//! ALB and API Gateway and VPC Lattice request adaptations //! -//! Typically these are exposed via the [`request_context()`] or [`request_context_ref()`] +//! Typically, these are exposed via the [`request_context()`] or [`request_context_ref()`] //! request extension methods provided by the [`RequestExt`] trait. //! //! [`request_context()`]: crate::RequestExt::request_context() @@ -12,7 +12,8 @@ use crate::ext::extensions::{PathParameters, StageVariables}; feature = "apigw_rest", feature = "apigw_http", feature = "alb", - feature = "apigw_websockets" + feature = "apigw_websockets", + feature = "vpc_lattice" ))] use crate::ext::extensions::{QueryStringParameters, RawHttpPath}; #[cfg(feature = "alb")] @@ -25,6 +26,9 @@ use aws_lambda_events::apigw::{ApiGatewayProxyRequest, ApiGatewayProxyRequestCon use aws_lambda_events::apigw::{ApiGatewayV2httpRequest, ApiGatewayV2httpRequestContext}; #[cfg(feature = "apigw_websockets")] use aws_lambda_events::apigw::{ApiGatewayWebsocketProxyRequest, ApiGatewayWebsocketProxyRequestContext}; +#[cfg(feature = "vpc_lattice")] +use aws_lambda_events::vpc_lattice::{VpcLatticeRequestV2, VpcLatticeRequestV2Context}; + use aws_lambda_events::{encodings::Body, query_map::QueryMap}; use http::{header::HeaderName, HeaderMap, HeaderValue}; @@ -35,7 +39,7 @@ use std::{env, future::Future, io::Read, pin::Pin}; use url::Url; /// Internal representation of an Lambda http event from -/// ALB, API Gateway REST and HTTP API proxy event perspectives +/// ALB, VPC Lattice Lambda, API Gateway REST and HTTP API proxy event perspectives /// /// This is not intended to be a type consumed by crate users directly. The order /// of the variants are notable. Serde will try to deserialize in this order. @@ -51,6 +55,8 @@ pub enum LambdaRequest { Alb(AlbTargetGroupRequest), #[cfg(feature = "apigw_websockets")] WebSocket(ApiGatewayWebsocketProxyRequest), + #[cfg(feature = "vpc_lattice")] + VpcLatticeV2(VpcLatticeRequestV2), #[cfg(feature = "pass_through")] PassThrough(String), } @@ -69,15 +75,18 @@ impl LambdaRequest { LambdaRequest::Alb { .. } => RequestOrigin::Alb, #[cfg(feature = "apigw_websockets")] LambdaRequest::WebSocket { .. } => RequestOrigin::WebSocket, + #[cfg(feature = "vpc_lattice")] + LambdaRequest::VpcLatticeV2 { .. } => RequestOrigin::VpcLatticeV2, #[cfg(feature = "pass_through")] LambdaRequest::PassThrough { .. } => RequestOrigin::PassThrough, #[cfg(not(any( feature = "apigw_rest", feature = "apigw_http", feature = "alb", - feature = "apigw_websockets" + feature = "apigw_websockets", + feature = "vpc_lattice", )))] - _ => compile_error!("Either feature `apigw_rest`, `apigw_http`, `alb`, or `apigw_websockets` must be enabled for the `lambda-http` crate."), + _ => compile_error!("Either feature `apigw_rest`, `apigw_http`, `alb`, `apigw_websockets` or `vpc_lattice` must be enabled for the `lambda-http` crate."), } } } @@ -102,6 +111,9 @@ pub enum RequestOrigin { /// API Gateway WebSocket #[cfg(feature = "apigw_websockets")] WebSocket, + /// VPC Lattice origin + #[cfg(feature = "vpc_lattice")] + VpcLatticeV2, /// PassThrough request origin #[cfg(feature = "pass_through")] PassThrough, @@ -274,7 +286,7 @@ fn into_alb_request(alb: AlbTargetGroupRequest) -> http::Request { req } -#[cfg(feature = "alb")] +#[cfg(any(feature = "alb", feature = "vpc_lattice"))] fn decode_query_map(query_map: QueryMap) -> QueryMap { use std::str::FromStr; @@ -337,6 +349,43 @@ fn into_websocket_request(ag: ApiGatewayWebsocketProxyRequest) -> http::Request< req } + +#[cfg(feature = "vpc_lattice")] +fn into_vpc_lattice_request(vlr: VpcLatticeRequestV2) -> http::Request { + let http_method = vlr.method; + let host = vlr.headers.get(http::header::HOST).and_then(|s| s.to_str().ok()); + let raw_path = vlr.path.unwrap_or_default(); + + let query_string_parameters = decode_query_map(vlr.query_string_parameters); + + let builder = http::Request::builder() + .uri(build_request_uri( + &raw_path, + &vlr.headers, + host, + Some((&query_string_parameters, &query_string_parameters)), + )) + .extension(RawHttpPath(raw_path)) + .extension(QueryStringParameters(query_string_parameters)) + .extension(RequestContext::VpcLattice(vlr.request_context)); + + let base64 = vlr.is_base64_encoded; + + let mut req = builder + .body( + vlr + .body + .as_deref() + .map_or_else(Body::default, |b| Body::from_maybe_encoded(base64, b)), + ) + .expect("failed to build request"); + + // no builder method that sets headers in batch + let _ = std::mem::replace(req.headers_mut(), vlr.headers); + let _ = std::mem::replace(req.method_mut(), http_method.unwrap_or(http::Method::GET)); + + req +} #[cfg(feature = "pass_through")] fn into_pass_through_request(data: String) -> http::Request { let mut builder = http::Request::builder(); @@ -393,6 +442,9 @@ pub enum RequestContext { /// WebSocket request context #[cfg(feature = "apigw_websockets")] WebSocket(ApiGatewayWebsocketProxyRequestContext), + /// VPC Lattice request context + #[cfg(feature = "vpc_lattice")] + VpcLattice(VpcLatticeRequestV2Context), /// Custom request context #[cfg(feature = "pass_through")] PassThrough, @@ -410,6 +462,8 @@ impl From for http::Request { LambdaRequest::Alb(alb) => into_alb_request(alb), #[cfg(feature = "apigw_websockets")] LambdaRequest::WebSocket(ag) => into_websocket_request(ag), + #[cfg(feature = "vpc_lattice")] + LambdaRequest::VpcLatticeV2(vpclat) => into_vpc_lattice_request(vpclat), #[cfg(feature = "pass_through")] LambdaRequest::PassThrough(data) => into_pass_through_request(data), } @@ -773,6 +827,183 @@ mod tests { ); } + #[test] + fn deserializes_vpc_lattice_basic() { + let input = include_str!("../tests/data/vpc_lattice_v2_request.json"); + let result = from_str(input); + assert!( + result.is_ok(), + "event is was not parsed as expected {result:?} given {input}" + ); + let request = result.expect("failed to parse request"); + assert_eq!(request.method(), "GET"); + + let body_str = match request.body() { + Body::Text(s) => s.as_str(), + _ => "" + }; + + assert_eq!(body_str, "All is good"); + + let uri = request.uri().to_string(); + assert!(uri.starts_with("/health?"), "unexpected uri: {uri}"); + assert!(uri.contains("multi=a"), "unexpected uri: {uri}"); + assert!(uri.contains("multi=DEF"), "unexpected uri: {uri}"); + assert!(uri.contains("multi=g"), "unexpected uri: {uri}"); + assert!(uri.contains("state=prod"), "unexpected uri: {uri}"); + + // Ensure this is an VPC Lattice request + let req_context = request.request_context_ref().expect("Request is missing RequestContext"); + assert!( + matches!(req_context, &RequestContext::VpcLattice(_)), + "expected Vpc lattice context, got {req_context:?}" + ); + } + + #[test] + fn deserializes_vpc_lattice_basic_base64() { + let input = include_str!("../tests/data/vpc_lattice_v2_request_base64.json"); + let result = from_str(input); + assert!( + result.is_ok(), + "event is was not parsed as expected {result:?} given {input}" + ); + let request = result.expect("failed to parse request"); + assert_eq!(request.method(), "GET"); + + let body_array = match request.body() { + Body::Binary(s) => s.as_slice(), + _ => &[] + }; + + assert_eq!(body_array, *b"All is good"); + + // URI should have been built from host header, query and protocol etc + let uri = request.uri(); + + assert!(uri.to_string().starts_with("https://www.site.com/health?")); + assert!(uri.to_string().contains("multi=a&multi=DEF&multi=g")); + assert!(uri.to_string().contains("state=prod")); + + // Ensure this is an VPC Lattice request + let req_context = request.request_context_ref().expect("Request is missing RequestContext"); + assert!( + matches!(req_context, &RequestContext::VpcLattice(_)), + "expected Vpc lattice context, got {req_context:?}" + ); + } + + #[test] + fn deserializes_vpc_lattice_headers() { + let input = include_str!("../tests/data/vpc_lattice_v2_request.json"); + let result = from_str(input); + assert!( + result.is_ok(), + "event is was not parsed as expected {result:?} given {input}" + ); + let request = result.expect("failed to parse request"); + + // decoding multi value headers + let multi_headers_as_big_string = request + .headers() + .get_all("multi") + .iter() + .map(|v| v.to_str().unwrap().to_string()) + .reduce(|acc, nxt| [acc, nxt].join(";")); + + assert_eq!(multi_headers_as_big_string, Some("x;y".to_string())); + + // decoding regular headers + let basic_headers_as_big_string = request + .headers() + .get_all("user-agent") + .iter() + .map(|v| v.to_str().unwrap().to_string()) + .reduce(|acc, nxt| [acc, nxt].join(";")); + + assert_eq!(basic_headers_as_big_string, Some("curl/7.68.0".to_string())); + + // Ensure this is an VPC Lattice request + let req_context = request.request_context_ref().expect("Request is missing RequestContext"); + assert!( + matches!(req_context, &RequestContext::VpcLattice(_)), + "expected Vpc lattice context, got {req_context:?}" + ); + } + + #[test] + fn deserializes_vpc_lattice_multi_value_querys() { + let input = include_str!("../tests/data/vpc_lattice_v2_request.json"); + let result = from_str(input); + assert!( + result.is_ok(), + "event is was not parsed as expected {result:?} given {input}" + ); + let request = result.expect("failed to parse request"); + assert!(!request + .query_string_parameters_ref() + .expect("Request is missing query parameters") + .is_empty()); + + let params = request.query_string_parameters(); + assert_eq!(Some(vec!["prod"]), params.all("state")); + assert_eq!(Some(vec!["a", "DEF", "g"]), params.all("multi")); + + let query = request.uri().query().unwrap(); + assert!(query.contains("multi=a&multi=DEF&multi=g")); + assert!(query.contains("state=prod")); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn deserializes_vpc_lattice_encoded_query_parameters() { + let input = include_str!("../tests/data/vpc_lattice_v2_request_encoded_query.json"); + let result = from_str(input); + assert!( + result.is_ok(), + "event is was not parsed as expected {result:?} given {input}" + ); + let request = result.expect("failed to parse request"); + + let params = request.query_string_parameters(); + // percent-encoded values should be decoded + assert_eq!(Some(vec!["?showAll=true"]), params.all("filter")); + assert_eq!(Some(vec!["hello world"]), params.all("q")); + + let query = request.uri().query().unwrap(); + assert!(query.contains("filter="), "unexpected uri query: {query}"); + assert!(query.contains("q="), "unexpected uri query: {query}"); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn deserializes_vpc_lattice_no_body() { + let input = r#"{ + "version": "2.0", + "path": "/ping", + "method": "GET", + "headers": {"accept": ["*/*"]}, + "queryStringParameters": {}, + "isBase64Encoded": false, + "requestContext": { + "serviceNetworkArn": "arn:aws:vpc-lattice:us-east-1:123456789012:servicenetwork/sn-abc", + "serviceArn": "arn:aws:vpc-lattice:us-east-1:123456789012:service/svc-abc", + "targetGroupArn": "arn:aws:vpc-lattice:us-east-1:123456789012:targetgroup/tg-abc", + "region": "us-east-1", + "timeEpoch": "1724875399456789" + } + }"#; + let result = from_str(input); + assert!(result.is_ok(), "event was not parsed as expected {result:?}"); + let request = result.expect("failed to parse request"); + assert_eq!(request.method(), "GET"); + assert!( + matches!(request.body(), Body::Empty), + "expected empty body, got {:?}", + request.body() + ); + } + #[test] fn deserialize_apigw_http_sam_local() { // manually generated from AWS SAM CLI diff --git a/lambda-http/src/response.rs b/lambda-http/src/response.rs index 629820ad6..5cdf084ec 100644 --- a/lambda-http/src/response.rs +++ b/lambda-http/src/response.rs @@ -7,6 +7,8 @@ use aws_lambda_events::alb::AlbTargetGroupResponse; use aws_lambda_events::apigw::ApiGatewayProxyResponse; #[cfg(feature = "apigw_http")] use aws_lambda_events::apigw::ApiGatewayV2httpResponse; +#[cfg(feature = "vpc_lattice")] +use aws_lambda_events::vpc_lattice::VpcLatticeResponse; use aws_lambda_events::encodings::Body; use encoding_rs::Encoding; use http::{ @@ -51,6 +53,8 @@ pub enum LambdaResponse { ApiGatewayV2(ApiGatewayV2httpResponse), #[cfg(feature = "alb")] Alb(AlbTargetGroupResponse), + #[cfg(feature = "vpc_lattice")] + VpcLattice(VpcLatticeResponse), #[cfg(feature = "pass_through")] PassThrough(serde_json::Value), } @@ -160,6 +164,21 @@ impl LambdaResponse { } response }), + #[cfg(feature = "vpc_lattice")] + RequestOrigin::VpcLatticeV2 => LambdaResponse::VpcLattice({ + let mut response = VpcLatticeResponse::default(); + + response.body = body; + response.is_base64_encoded = is_base64_encoded; + response.status_code = status_code as u16; + response.headers = headers; + // Today, this implementation doesn't provide any additional fields + #[cfg(feature = "catch-all-fields")] + { + response.other = Default::default(); + } + response + }), #[cfg(feature = "pass_through")] RequestOrigin::PassThrough => { match body { @@ -173,9 +192,10 @@ impl LambdaResponse { feature = "apigw_rest", feature = "apigw_http", feature = "alb", - feature = "apigw_websockets" + feature = "apigw_websockets", + feature = "vpc_lattice" )))] - _ => compile_error!("Either feature `apigw_rest`, `apigw_http`, `alb`, or `apigw_websockets` must be enabled for the `lambda-http` crate."), + _ => compile_error!("Either feature `apigw_rest`, `apigw_http`, `alb`, `apigw_websockets` or `vpc_lattice` must be enabled for the `lambda-http` crate."), } } } @@ -584,6 +604,59 @@ mod tests { ) } + #[test] + #[cfg(feature = "vpc_lattice")] + fn serialize_vpc_lattice_response_text_body() { + let res = LambdaResponse::from_response( + &RequestOrigin::VpcLatticeV2, + Response::builder() + .status(200) + .header("content-type", "text/plain") + .body(Body::from("hello")) + .expect("failed to create response"), + ); + let json = serde_json::to_string(&res).expect("failed to serialize to json"); + assert_eq!( + json, + r#"{"isBase64Encoded":false,"statusCode":200,"headers":{"content-type":"text/plain"},"body":"hello"}"# + ); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn serialize_vpc_lattice_response_binary_body() { + let res = LambdaResponse::from_response( + &RequestOrigin::VpcLatticeV2, + Response::builder() + .status(200) + .body(Body::from(b"hello".as_slice())) + .expect("failed to create response"), + ); + let json = serde_json::to_string(&res).expect("failed to serialize to json"); + assert_eq!( + json, + r#"{"isBase64Encoded":true,"statusCode":200,"body":"aGVsbG8="}"# + ); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn serialize_vpc_lattice_response_non_200_status() { + let res = LambdaResponse::from_response( + &RequestOrigin::VpcLatticeV2, + Response::builder() + .status(404) + .header("x-request-id", "abc-123") + .body(Body::from(())) + .expect("failed to create response"), + ); + let json = serde_json::to_string(&res).expect("failed to serialize to json"); + assert_eq!( + json, + r#"{"isBase64Encoded":false,"statusCode":404,"headers":{"x-request-id":"abc-123"},"body":null}"# + ); + } + #[test] fn serialize_multi_value_headers() { let res = LambdaResponse::from_response( diff --git a/lambda-http/tests/data/vpc_lattice_v2_request.json b/lambda-http/tests/data/vpc_lattice_v2_request.json new file mode 100644 index 000000000..2ac3920f9 --- /dev/null +++ b/lambda-http/tests/data/vpc_lattice_v2_request.json @@ -0,0 +1,30 @@ +{ + "version": "2.0", + "path": "/health", + "method": "GET", + "headers": { + "accept": ["*/*"], + "user-agent": ["curl/7.68.0"], + "x-forwarded-for": ["10.0.2.100"], + "multi": ["x", "y"] + }, + "queryStringParameters": { + "state": ["prod"], + "multi": ["a", "DEF", "g"] + }, + "body": "All is good", + "isBase64Encoded": false, + "requestContext": { + "serviceNetworkArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a", + "serviceArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:service/svc-0a40eebed65f8d69c", + "targetGroupArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + "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" + } +} \ No newline at end of file diff --git a/lambda-http/tests/data/vpc_lattice_v2_request_base64.json b/lambda-http/tests/data/vpc_lattice_v2_request_base64.json new file mode 100644 index 000000000..c622a775f --- /dev/null +++ b/lambda-http/tests/data/vpc_lattice_v2_request_base64.json @@ -0,0 +1,31 @@ +{ + "version": "2.0", + "path": "/health", + "method": "GET", + "headers": { + "host": "www.site.com", + "accept": ["*/*"], + "user-agent": ["curl/7.68.0"], + "x-forwarded-for": ["10.0.2.100"], + "multi": ["x", "y"] + }, + "queryStringParameters": { + "state": ["prod"], + "multi": ["a", "DEF", "g"] + }, + "body": "QWxsIGlzIGdvb2Q=", + "isBase64Encoded": true, + "requestContext": { + "serviceNetworkArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a", + "serviceArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:service/svc-0a40eebed65f8d69c", + "targetGroupArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + "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" + } +} \ No newline at end of file diff --git a/lambda-http/tests/data/vpc_lattice_v2_request_encoded_query.json b/lambda-http/tests/data/vpc_lattice_v2_request_encoded_query.json new file mode 100644 index 000000000..a848638e3 --- /dev/null +++ b/lambda-http/tests/data/vpc_lattice_v2_request_encoded_query.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "path": "/search", + "method": "GET", + "headers": { + "host": ["api.example.com"], + "accept": ["*/*"] + }, + "queryStringParameters": { + "filter": ["%3FshowAll%3Dtrue"], + "q": ["hello%20world"] + }, + "isBase64Encoded": false, + "requestContext": { + "serviceNetworkArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a", + "serviceArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:service/svc-0a40eebed65f8d69c", + "targetGroupArn": "arn:aws:vpc-lattice:ap-southeast-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + "region": "ap-southeast-2", + "timeEpoch": "1724875399456789" + } +} \ No newline at end of file From f40ff2c91c5c08ac4e2ae6ee4d8e81646a2241dc Mon Sep 17 00:00:00 2001 From: andrewpatto Date: Wed, 8 Apr 2026 14:06:29 +1000 Subject: [PATCH 2/3] fmt, clippy markdown fix --- lambda-http/src/deserializer.rs | 8 +++++--- lambda-http/src/lib.rs | 6 +++--- lambda-http/src/request.rs | 20 ++++++++++++-------- lambda-http/src/response.rs | 9 +++------ 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/lambda-http/src/deserializer.rs b/lambda-http/src/deserializer.rs index 0de29de13..776151995 100644 --- a/lambda-http/src/deserializer.rs +++ b/lambda-http/src/deserializer.rs @@ -145,13 +145,15 @@ mod tests { #[test] fn test_deserialize_vpc_lattice() { - let data = - include_bytes!("../../lambda-events/src/fixtures/example-vpc-lattice-v2-request.json"); + let data = include_bytes!("../../lambda-events/src/fixtures/example-vpc-lattice-v2-request.json"); let req: LambdaRequest = serde_json::from_slice(data).expect("failed to deserialize vpc lattice data"); match req { LambdaRequest::VpcLatticeV2(req) => { - assert_eq!("arn:aws:vpc-lattice:ap-southeast-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", req.request_context.target_group_arn); + assert_eq!( + "arn:aws:vpc-lattice:ap-southeast-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + req.request_context.target_group_arn + ); } other => panic!("unexpected request variant: {other:?}"), } diff --git a/lambda-http/src/lib.rs b/lambda-http/src/lib.rs index a1e851259..496110db9 100644 --- a/lambda-http/src/lib.rs +++ b/lambda-http/src/lib.rs @@ -3,9 +3,9 @@ //#![deny(warnings)] //! Enriches the `lambda` crate with [`http`](https://github.com/hyperium/http) //! types targeting AWS -//! [ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html), -//! [API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html), -//! [VPC Lattice](https://docs.aws.amazon.com/vpc-lattice/latest/ug/lambda-functions.html) REST and HTTP API lambda integrations. +//! * [ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html) +//! * [API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html) REST and HTTP API lambda integrations +//! * [VPC Lattice](https://docs.aws.amazon.com/vpc-lattice/latest/ug/lambda-functions.html) //! //! This crate abstracts over all of these trigger events using standard [`http`](https://github.com/hyperium/http) types minimizing the mental overhead //! of understanding the nuances and variation between trigger details allowing you to focus more on your application while also giving you to the maximum flexibility to diff --git a/lambda-http/src/request.rs b/lambda-http/src/request.rs index 9526591d9..36528dc23 100644 --- a/lambda-http/src/request.rs +++ b/lambda-http/src/request.rs @@ -349,7 +349,6 @@ fn into_websocket_request(ag: ApiGatewayWebsocketProxyRequest) -> http::Request< req } - #[cfg(feature = "vpc_lattice")] fn into_vpc_lattice_request(vlr: VpcLatticeRequestV2) -> http::Request { let http_method = vlr.method; @@ -373,8 +372,7 @@ fn into_vpc_lattice_request(vlr: VpcLatticeRequestV2) -> http::Request { let mut req = builder .body( - vlr - .body + vlr.body .as_deref() .map_or_else(Body::default, |b| Body::from_maybe_encoded(base64, b)), ) @@ -840,7 +838,7 @@ mod tests { let body_str = match request.body() { Body::Text(s) => s.as_str(), - _ => "" + _ => "", }; assert_eq!(body_str, "All is good"); @@ -853,7 +851,9 @@ mod tests { assert!(uri.contains("state=prod"), "unexpected uri: {uri}"); // Ensure this is an VPC Lattice request - let req_context = request.request_context_ref().expect("Request is missing RequestContext"); + let req_context = request + .request_context_ref() + .expect("Request is missing RequestContext"); assert!( matches!(req_context, &RequestContext::VpcLattice(_)), "expected Vpc lattice context, got {req_context:?}" @@ -873,7 +873,7 @@ mod tests { let body_array = match request.body() { Body::Binary(s) => s.as_slice(), - _ => &[] + _ => &[], }; assert_eq!(body_array, *b"All is good"); @@ -886,7 +886,9 @@ mod tests { assert!(uri.to_string().contains("state=prod")); // Ensure this is an VPC Lattice request - let req_context = request.request_context_ref().expect("Request is missing RequestContext"); + let req_context = request + .request_context_ref() + .expect("Request is missing RequestContext"); assert!( matches!(req_context, &RequestContext::VpcLattice(_)), "expected Vpc lattice context, got {req_context:?}" @@ -924,7 +926,9 @@ mod tests { assert_eq!(basic_headers_as_big_string, Some("curl/7.68.0".to_string())); // Ensure this is an VPC Lattice request - let req_context = request.request_context_ref().expect("Request is missing RequestContext"); + let req_context = request + .request_context_ref() + .expect("Request is missing RequestContext"); assert!( matches!(req_context, &RequestContext::VpcLattice(_)), "expected Vpc lattice context, got {req_context:?}" diff --git a/lambda-http/src/response.rs b/lambda-http/src/response.rs index 5cdf084ec..da3873225 100644 --- a/lambda-http/src/response.rs +++ b/lambda-http/src/response.rs @@ -7,9 +7,9 @@ use aws_lambda_events::alb::AlbTargetGroupResponse; use aws_lambda_events::apigw::ApiGatewayProxyResponse; #[cfg(feature = "apigw_http")] use aws_lambda_events::apigw::ApiGatewayV2httpResponse; +use aws_lambda_events::encodings::Body; #[cfg(feature = "vpc_lattice")] use aws_lambda_events::vpc_lattice::VpcLatticeResponse; -use aws_lambda_events::encodings::Body; use encoding_rs::Encoding; use http::{ header::{CONTENT_ENCODING, CONTENT_TYPE}, @@ -170,7 +170,7 @@ impl LambdaResponse { response.body = body; response.is_base64_encoded = is_base64_encoded; - response.status_code = status_code as u16; + response.status_code = status_code; response.headers = headers; // Today, this implementation doesn't provide any additional fields #[cfg(feature = "catch-all-fields")] @@ -633,10 +633,7 @@ mod tests { .expect("failed to create response"), ); let json = serde_json::to_string(&res).expect("failed to serialize to json"); - assert_eq!( - json, - r#"{"isBase64Encoded":true,"statusCode":200,"body":"aGVsbG8="}"# - ); + assert_eq!(json, r#"{"isBase64Encoded":true,"statusCode":200,"body":"aGVsbG8="}"#); } #[test] From 3064aec010ef36ee2f18a6bdc0c2b71e8826726c Mon Sep 17 00:00:00 2001 From: andrewpatto Date: Wed, 8 Apr 2026 14:33:41 +1000 Subject: [PATCH 3/3] Tidied documentation --- README.md | 2 +- lambda-http/README.md | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6b7addf8a..4b893b4f3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This package makes it easy to run AWS Lambda Functions written in Rust. This workspace includes multiple crates: - [![Docs](https://docs.rs/lambda_runtime/badge.svg)](https://docs.rs/lambda_runtime) **`lambda-runtime`** is a library that provides a Lambda runtime for applications written in Rust. -- [![Docs](https://docs.rs/lambda_http/badge.svg)](https://docs.rs/lambda_http) **`lambda-http`** is a library that makes it easy to write API Gateway proxy event focused Lambda functions in Rust. +- [![Docs](https://docs.rs/lambda_http/badge.svg)](https://docs.rs/lambda_http) **`lambda-http`** is a library for writing HTTP Lambda functions in Rust, with support for upstream API Gateway, ALB, Lambda Function URLs, and VPC Lattice. - [![Docs](https://docs.rs/lambda-extension/badge.svg)](https://docs.rs/lambda-extension) **`lambda-extension`** is a library that makes it easy to write Lambda Runtime Extensions in Rust. - [![Docs](https://docs.rs/aws_lambda_events/badge.svg)](https://docs.rs/aws_lambda_events) **`lambda-events`** is a library with strongly-typed Lambda event structs in Rust. - [![Docs](https://docs.rs/lambda_runtime_api_client/badge.svg)](https://docs.rs/lambda_runtime_api_client) **`lambda-runtime-api-client`** is a shared library between the lambda runtime and lambda extension libraries that includes a common API client to talk with the AWS Lambda Runtime API. diff --git a/lambda-http/README.md b/lambda-http/README.md index 566623820..256c40d98 100644 --- a/lambda-http/README.md +++ b/lambda-http/README.md @@ -2,7 +2,9 @@ [![Docs](https://docs.rs/lambda_http/badge.svg)](https://docs.rs/lambda_http) -**`lambda-http`** is an abstraction that takes payloads from different services and turns them into http objects, making it easy to write API Gateway proxy event focused Lambda functions in Rust. +**`lambda-http`** is an abstraction that takes HTTP-shaped payloads from different AWS services and turns them +into standard `http` objects, making it easy to write Lambda functions in Rust that serve HTTP traffic +regardless of the upstream trigger. lambda-http handler is made of: @@ -14,17 +16,18 @@ We are able to handle requests from: * [API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html) REST, HTTP and WebSockets API lambda integrations * AWS [ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html) * AWS [Lambda function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) +* AWS [VPC Lattice](https://docs.aws.amazon.com/vpc-lattice/latest/ug/lambda-functions.html) -Thanks to the `Request` type we can seamlessly handle proxy integrations without the worry to specify the specific service type. +Thanks to the `Request` type we can seamlessly handle all of these triggers without having to write service-specific code. -There is also an extension for `lambda_http::Request` structs that provides access to [API gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format) and [ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html) features. +There is also an extension for `lambda_http::Request` structs that provides access to [API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format), [ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html), and [VPC Lattice](https://docs.aws.amazon.com/vpc-lattice/latest/ug/lambda-functions.html) features. For example some handy extensions: * `query_string_parameters` - Return pre-parsed http query string parameters, parameters provided after the `?` portion of a url associated with the request * `path_parameters` - Return pre-extracted path parameters, parameter provided in url placeholders `/foo/{bar}/baz/{qux}` associated with the request * `lambda_context` - Return the Lambda context for the invocation; see the [runtime docs](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-next) -* `request_context` - Return the ALB/API Gateway request context +* `request_context` - Return the ALB, API Gateway, or VPC Lattice request context * payload - Return the Result of a payload parsed into a type that implements `serde::Deserialize` See the `lambda_http::RequestPayloadExt` and `lambda_http::RequestExt` traits for more extensions. @@ -238,16 +241,17 @@ If you don't want to receive the stage as part of the path, you can set the envi ## Feature flags -`lambda_http` is a wrapper for HTTP events coming from three different services, Amazon Load Balancer (ALB), Amazon Api Gateway (APIGW), and AWS Lambda Function URLs. Amazon Api Gateway can also send events from three different endpoints, REST APIs, HTTP APIs, and WebSockets. `lambda_http` transforms events from all these sources into native `http::Request` objects, so you can incorporate Rust HTTP semantics into your Lambda functions. +`lambda_http` is a wrapper for HTTP events coming from four different AWS services: Application Load Balancer (ALB), API Gateway, Lambda Function URLs, and VPC Lattice. API Gateway can send events from three different endpoint types: REST APIs, HTTP APIs, and WebSockets. `lambda_http` transforms events from all these sources into native `http::Request` objects, so you can incorporate Rust HTTP semantics into your Lambda functions. By default, `lambda_http` compiles your function to support any of those services. This increases the compile time of your function because we have to generate code for all the sources. In reality, you'll usually put a Lambda function only behind one of those sources. You can choose which source to generate code for with feature flags. -The available features flags for `lambda_http` are the following: +The available feature flags for `lambda_http` are the following: - `alb`: for events coming from [Amazon Elastic Load Balancer](https://aws.amazon.com/elasticloadbalancing/). -- `apigw_rest`: for events coming from [Amazon API Gateway Rest APIs](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html). +- `apigw_rest`: for events coming from [Amazon API Gateway REST APIs](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html). - `apigw_http`: for events coming from [Amazon API Gateway HTTP APIs](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) and [AWS Lambda Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html). - `apigw_websockets`: for events coming from [Amazon API Gateway WebSockets](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api.html). +- `vpc_lattice`: for events coming from [AWS VPC Lattice](https://docs.aws.amazon.com/vpc-lattice/latest/ug/lambda-functions.html). If you only want to support one of these sources, you can disable the default features, and enable only the source that you care about in your package's `Cargo.toml` file. Substitute the dependency line for `lambda_http` for the snippet below, changing the feature that you want to enable: