diff --git a/Makefile b/Makefile index 155b7ea1..64685468 100644 --- a/Makefile +++ b/Makefile @@ -106,6 +106,7 @@ check-event-features: cargo test --package aws_lambda_events --no-default-features --features sns cargo test --package aws_lambda_events --no-default-features --features sqs cargo test --package aws_lambda_events --no-default-features --features streams + cargo test --package aws_lambda_events --no-default-features --features vpc_lattice fmt: cargo +nightly fmt --all diff --git a/lambda-events/Cargo.toml b/lambda-events/Cargo.toml index 2cd8c969..892182e8 100644 --- a/lambda-events/Cargo.toml +++ b/lambda-events/Cargo.toml @@ -78,6 +78,7 @@ default = [ "streams", "documentdb", "eventbridge", + "vpc_lattice" ] activemq = [] @@ -124,6 +125,7 @@ sqs = ["serde_with"] streams = [] documentdb = [] eventbridge = ["chrono", "serde_with"] +vpc_lattice = ["bytes", "http", "http-body", "http-serde", "iam", "query_map"] catch-all-fields = [] diff --git a/lambda-events/src/event/mod.rs b/lambda-events/src/event/mod.rs index 275450fd..44ead0bb 100644 --- a/lambda-events/src/event/mod.rs +++ b/lambda-events/src/event/mod.rs @@ -201,3 +201,8 @@ pub mod documentdb; #[cfg(feature = "eventbridge")] #[cfg_attr(docsrs, doc(cfg(feature = "eventbridge")))] pub mod eventbridge; + +/// AWS Lambda event definitions for VPC Lattice. +#[cfg(feature = "vpc_lattice")] +#[cfg_attr(docsrs, doc(cfg(feature = "vpc_lattice")))] +pub mod vpc_lattice; diff --git a/lambda-events/src/event/vpc_lattice/common.rs b/lambda-events/src/event/vpc_lattice/common.rs new file mode 100644 index 00000000..724a01b2 --- /dev/null +++ b/lambda-events/src/event/vpc_lattice/common.rs @@ -0,0 +1,58 @@ +use crate::custom_serde::{deserialize_headers, serialize_headers}; +use crate::encodings::Body; +use http::HeaderMap; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "catch-all-fields")] +use serde_json::Value; + +/// `VpcLatticeResponse` configures the response to be returned +/// by VPC Lattice (both V1 and V2) for the request +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VpcLatticeResponse { + // https://docs.aws.amazon.com/vpc-lattice/latest/ug/lambda-functions.html#respond-to-service + /// Whether the body is base64 encoded + #[serde(default)] + pub is_base64_encoded: bool, + + /// The HTTP status code for the request + pub status_code: u16, + + /// The HTTP status description (optional) + #[serde(default)] + pub status_description: Option, + + /// The Http headers to return + #[serde(deserialize_with = "deserialize_headers")] + #[serde(serialize_with = "serialize_headers")] + #[serde(skip_serializing_if = "HeaderMap::is_empty")] + #[serde(default)] + pub headers: HeaderMap, + + /// The response body + #[serde(default)] + pub body: Option, + + /// Catchall to catch any additional fields that were present but not explicitly defined by this struct. + /// Enabled with Cargo feature `catch-all-fields`. + /// If `catch-all-fields` is disabled, any additional fields that are present will be ignored. + #[cfg(feature = "catch-all-fields")] + #[cfg_attr(docsrs, doc(cfg(feature = "catch-all-fields")))] + #[serde(flatten)] + pub other: serde_json::Map, +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[cfg(feature = "vpc_lattice")] + fn example_alb_lambda_target_response() { + let data = include_bytes!("../../fixtures/example-vpc-lattice-response.json"); + let parsed: VpcLatticeResponse = serde_json::from_slice(data).unwrap(); + let output: String = serde_json::to_string(&parsed).unwrap(); + let reparsed: VpcLatticeResponse = serde_json::from_slice(output.as_bytes()).unwrap(); + assert_eq!(parsed, reparsed); + } +} diff --git a/lambda-events/src/event/vpc_lattice/mod.rs b/lambda-events/src/event/vpc_lattice/mod.rs new file mode 100644 index 00000000..211387d8 --- /dev/null +++ b/lambda-events/src/event/vpc_lattice/mod.rs @@ -0,0 +1,10 @@ +mod common; +mod serialization_comma_separated_headers; +mod v1; +mod v2; + +// re-export types +pub use self::{common::*, v1::*, v2::*}; + +// helper code +pub(crate) use self::serialization_comma_separated_headers::*; diff --git a/lambda-events/src/event/vpc_lattice/serialization_comma_separated_headers.rs b/lambda-events/src/event/vpc_lattice/serialization_comma_separated_headers.rs new file mode 100644 index 00000000..8ba0f41e --- /dev/null +++ b/lambda-events/src/event/vpc_lattice/serialization_comma_separated_headers.rs @@ -0,0 +1,231 @@ +use http::{header::HeaderName, HeaderMap, HeaderValue}; +use serde::{ + de::{self, Deserializer, Error as DeError, MapAccess, Unexpected, Visitor}, + ser::{SerializeMap, Serializer}, +}; +use std::{borrow::Cow, fmt}; + +/// Deserialize (potentially) comma separated headers into a HeaderMap +pub(crate) fn deserialize_comma_separated_headers<'de, D>(de: D) -> Result +where + D: Deserializer<'de>, +{ + let is_human_readable = de.is_human_readable(); + de.deserialize_option(HeaderMapVisitor { is_human_readable }) +} + +/// Serialize a HeaderMap with multiple values per header combined as comma-separated strings +pub(crate) fn serialize_comma_separated_headers(headers: &HeaderMap, serializer: S) -> Result +where + S: Serializer, +{ + let mut map = serializer.serialize_map(Some(headers.keys_len()))?; + + // Group headers by name and combine values + for key in headers.keys() { + let values: Vec<&str> = headers + .get_all(key) + .iter() + .filter_map(|v| v.to_str().ok()) // Skip invalid UTF-8 values + .collect(); + + if !values.is_empty() { + let combined_value = values.join(", "); + map.serialize_entry(key.as_str(), &combined_value)?; + } + } + + map.end() +} + +// extension/duplicate of existing code from custom_serde/headers.rs +// could possibly be refactored back into common code + +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum OneOrMore<'a> { + One(Cow<'a, str>), + Strings(Vec>), + Bytes(Vec>), + CommaSeparated(Cow<'a, str>), +} + +struct HeaderMapVisitor { + is_human_readable: bool, +} + +impl<'de> Visitor<'de> for HeaderMapVisitor { + type Value = HeaderMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("lots of things can go wrong with HeaderMap") + } + + fn visit_none(self) -> Result + where + E: DeError, + { + Ok(HeaderMap::default()) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(self) + } + + fn visit_unit(self) -> Result + where + E: DeError, + { + Ok(HeaderMap::default()) + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let mut map = HeaderMap::with_capacity(access.size_hint().unwrap_or(0)); + + if !self.is_human_readable { + while let Some((key, arr)) = access.next_entry::, Vec>>()? { + let key = HeaderName::from_bytes(key.as_bytes()) + .map_err(|_| de::Error::invalid_value(Unexpected::Str(&key), &self))?; + for val in arr { + let val = HeaderValue::from_bytes(&val) + .map_err(|_| de::Error::invalid_value(Unexpected::Bytes(&val), &self))?; + map.append(&key, val); + } + } + } else { + while let Some((key, val)) = access.next_entry::, OneOrMore<'_>>()? { + let key = HeaderName::from_bytes(key.as_bytes()) + .map_err(|_| de::Error::invalid_value(Unexpected::Str(&key), &self))?; + match val { + OneOrMore::One(val) => { + // Check if the single value contains commas and split if needed + if val.contains(',') { + split_and_append_header(&mut map, &key, &val, &self)?; + } else { + let header_val = val + .parse() + .map_err(|_| de::Error::invalid_value(Unexpected::Str(&val), &self))?; + map.insert(key, header_val); + } + } + OneOrMore::Strings(arr) => { + for val in arr { + // Each string in the array might also be comma-separated + if val.contains(',') { + split_and_append_header(&mut map, &key, &val, &self)?; + } else { + let header_val = val + .parse() + .map_err(|_| de::Error::invalid_value(Unexpected::Str(&val), &self))?; + map.append(&key, header_val); + } + } + } + OneOrMore::Bytes(arr) => { + for val in arr { + let header_val = HeaderValue::from_bytes(&val) + .map_err(|_| de::Error::invalid_value(Unexpected::Bytes(&val), &self))?; + map.append(&key, header_val); + } + } + OneOrMore::CommaSeparated(val) => { + // Explicitly handle comma-separated values + split_and_append_header(&mut map, &key, &val, &self)?; + } + }; + } + } + Ok(map) + } +} + +fn split_and_append_header( + map: &mut HeaderMap, + key: &HeaderName, + value: &str, + visitor: &HeaderMapVisitor, +) -> Result<(), E> +where + E: DeError, +{ + for split_val in value.split(',') { + let trimmed_val = split_val.trim(); + if !trimmed_val.is_empty() { + // Skip empty values from trailing commas + let header_val = trimmed_val + .parse() + .map_err(|_| de::Error::invalid_value(Unexpected::Str(trimmed_val), visitor))?; + map.append(key, header_val); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue}; + use serde_json; + use serde_with::serde_derive::{Deserialize, Serialize}; + + #[test] + fn test_function_deserializer() { + #[derive(Deserialize)] + struct RequestWithHeaders { + #[serde(deserialize_with = "deserialize_comma_separated_headers")] + headers: HeaderMap, + } + + let r: RequestWithHeaders = + serde_json::from_str("{ \"headers\": {\"x-foo\": \"z\", \"x-multi\": \"abcd, DEF, w\" }}").unwrap(); + + assert_eq!("z", r.headers.get_all("x-foo").iter().next().unwrap()); + assert_eq!("abcd", r.headers.get_all("x-multi").iter().next().unwrap()); + assert_eq!("DEF", r.headers.get_all("x-multi").iter().nth(1).unwrap()); + assert_eq!("w", r.headers.get_all("x-multi").iter().nth(2).unwrap()); + } + + fn create_test_headermap() -> HeaderMap { + let mut headers = HeaderMap::new(); + + // Single value header + headers.insert("content-type", HeaderValue::from_static("application/json")); + + // Multiple value header + headers.append("accept", HeaderValue::from_static("text/html")); + headers.append("accept", HeaderValue::from_static("application/json")); + headers.append("accept", HeaderValue::from_static("*/*")); + + // Another multiple value header + headers.append("cache-control", HeaderValue::from_static("no-cache")); + headers.append("cache-control", HeaderValue::from_static("must-revalidate")); + + headers + } + + #[test] + fn test_function_serializer() { + #[derive(Serialize)] + struct RequestWithHeaders { + #[serde(serialize_with = "serialize_comma_separated_headers")] + headers: HeaderMap, + body: String, + } + + let request = RequestWithHeaders { + headers: create_test_headermap(), + body: "test body".to_string(), + }; + + let json = serde_json::to_string_pretty(&request).unwrap(); + + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed["headers"]["accept"].as_str().unwrap().contains(", ")); + } +} diff --git a/lambda-events/src/event/vpc_lattice/v1.rs b/lambda-events/src/event/vpc_lattice/v1.rs new file mode 100644 index 00000000..7487e84a --- /dev/null +++ b/lambda-events/src/event/vpc_lattice/v1.rs @@ -0,0 +1,90 @@ +use http::{HeaderMap, Method}; +use query_map::QueryMap; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "catch-all-fields")] +use serde_json::Value; + +use crate::{ + custom_serde::{deserialize_nullish_boolean, http_method}, + vpc_lattice::{deserialize_comma_separated_headers, serialize_comma_separated_headers}, +}; + +/// `VpcLatticeRequestV1` contains data coming from VPC Lattice service (V1 format) +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +// we note that V1 requests are snake cased UNLIKE v2 which are camel cased +#[serde(rename_all = "snake_case")] +pub struct VpcLatticeRequestV1 { + /// The url path for the request + #[serde(default)] + pub raw_path: Option, + + /// The HTTP method of the request + #[serde(with = "http_method")] + pub method: Method, + + /// HTTP headers of the request (V1 uses comma-separated strings for multi-values) + #[serde(deserialize_with = "deserialize_comma_separated_headers", default)] + #[serde(serialize_with = "serialize_comma_separated_headers")] + pub headers: HeaderMap, + + /// HTTP query string parameters (V1 uses the last value passed for multi-values + /// so no special serializer is needed) + #[serde(default)] + pub query_string_parameters: QueryMap, + + /// The request body + #[serde(default)] + pub body: Option, + + /// Whether the body is base64 encoded + #[serde(default, deserialize_with = "deserialize_nullish_boolean")] + pub is_base64_encoded: bool, + + /// Catchall to catch any additional fields + #[cfg(feature = "catch-all-fields")] + #[cfg_attr(docsrs, doc(cfg(feature = "catch-all-fields")))] + #[serde(flatten)] + pub other: serde_json::Map, +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[cfg(feature = "vpc_lattice")] + fn example_vpc_lattice_v1_deserialize() { + let data = include_bytes!("../../fixtures/example-vpc-lattice-v1-request.json"); + let parsed: VpcLatticeRequestV1 = serde_json::from_slice(data).unwrap(); + + assert_eq!("/api/product", parsed.raw_path.unwrap()); + assert_eq!("POST", parsed.method); + assert_eq!( + "curl/7.68.0", + parsed.headers.get_all("user-agent").iter().next().unwrap() + ); + assert_eq!("electronics", parsed.query_string_parameters.first("category").unwrap()); + assert_eq!("{\"id\": 5, \"description\": \"TV\"}", parsed.body.unwrap()); + assert!(!parsed.is_base64_encoded); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn example_vpc_lattice_v1_deserialize_headers_multi_values() { + let data = include_bytes!("../../fixtures/example-vpc-lattice-v1-request.json"); + let parsed: VpcLatticeRequestV1 = serde_json::from_slice(data).unwrap(); + + assert_eq!("abcd", parsed.headers.get_all("multi").iter().next().unwrap()); + assert_eq!("DEF", parsed.headers.get_all("multi").iter().nth(1).unwrap()); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn example_vpc_lattice_v1_deserialize_query_string_map() { + let data = include_bytes!("../../fixtures/example-vpc-lattice-v1-request.json"); + let parsed: VpcLatticeRequestV1 = serde_json::from_slice(data).unwrap(); + + assert_eq!("electronics", parsed.query_string_parameters.first("category").unwrap()); + assert_eq!("tv", parsed.query_string_parameters.first("tags").unwrap()); + } +} diff --git a/lambda-events/src/event/vpc_lattice/v2.rs b/lambda-events/src/event/vpc_lattice/v2.rs new file mode 100644 index 00000000..57790062 --- /dev/null +++ b/lambda-events/src/event/vpc_lattice/v2.rs @@ -0,0 +1,237 @@ +use crate::custom_serde::{ + deserialize_headers, deserialize_nullish_boolean, http_method, serialize_multi_value_headers, +}; +use http::{HeaderMap, Method}; +use query_map::QueryMap; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "catch-all-fields")] +use serde_json::Value; + +/// `VpcLatticeRequestV2` contains data coming from VPC Lattice service (V2 format) +/// see: https://docs.aws.amazon.com/vpc-lattice/latest/ug/lambda-functions.html#receive-event-from-service +/// for field definitions. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VpcLatticeRequestV2 { + /// The version of the event structure (always "2.0" for V2) + #[serde(default)] + pub version: Option, + + /// The url path for the request + #[serde(default)] + pub path: Option, + + /// The HTTP method of the request + #[serde(with = "http_method")] + pub method: Method, + + /// HTTP headers of the request (VPC Lattice V2 uses arrays for multi-values) + #[serde(default, deserialize_with = "deserialize_headers")] + #[serde(serialize_with = "serialize_multi_value_headers")] + pub headers: HeaderMap, + + /// HTTP query string parameters (VPC Lattice V2 uses arrays for multi-values) + #[serde(default)] + pub query_string_parameters: QueryMap, + + /// The request body + #[serde(default)] + pub body: Option, + + /// Whether the body is base64 encoded + #[serde(default, deserialize_with = "deserialize_nullish_boolean")] + pub is_base64_encoded: bool, + + /// VPC Lattice specific request context + #[serde(bound = "")] + pub request_context: VpcLatticeRequestV2Context, + + /// Catchall to catch any additional fields that were present but not explicitly defined by this struct. + /// Enabled with Cargo feature `catch-all-fields`. + /// If `catch-all-fields` is disabled, any additional fields that are present will be ignored. + #[cfg(feature = "catch-all-fields")] + #[cfg_attr(docsrs, doc(cfg(feature = "catch-all-fields")))] + #[serde(flatten)] + pub other: serde_json::Map, +} + +/// VPC Lattice specific request context +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VpcLatticeRequestV2Context { + /// ARN of the service network that delivers the request + #[serde(default)] + pub service_network_arn: Option, + + /// ARN of the service that receives the request + #[serde(default)] + pub service_arn: Option, + + /// ARN of the target group that receives the request + #[serde(default)] + pub target_group_arn: Option, + + /// Identity information for the request + #[serde(default)] + pub identity: Option, + + /// AWS region where the request is processed + #[serde(default)] + pub region: Option, + + /// Time of the request in microseconds since epoch + #[serde(default)] + pub time_epoch: Option, + + /// Catchall for additional context fields + #[cfg(feature = "catch-all-fields")] + #[cfg_attr(docsrs, doc(cfg(feature = "catch-all-fields")))] + #[serde(flatten)] + pub other: serde_json::Map, +} + +/// Identity information in VPC Lattice request context +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VpcLatticeRequestV2Identity { + /// ARN of the VPC where the request originated + #[serde(default)] + pub source_vpc_arn: Option, + + /// Type of authentication (e.g., "AWS_IAM") + #[serde(rename = "type")] + #[serde(default)] + pub identity_type: Option, + + /// The authenticated principal + #[serde(default)] + pub principal: Option, + + /// Organization ID of the authenticated principal + #[serde(rename = "principalOrgID")] + #[serde(default)] + pub principal_org_id: Option, + + /// Name of the authenticated session + #[serde(default)] + pub session_name: Option, + + /// X.509 certificate fields (for Roles Anywhere) + #[serde(rename = "x509IssuerOu")] + #[serde(default)] + pub x509_issuer_ou: Option, + #[serde(rename = "x509SanDns")] + #[serde(default)] + pub x509_san_dns: Option, + #[serde(rename = "x509SanNameCn")] + #[serde(default)] + pub x509_san_name_cn: Option, + #[serde(rename = "x509SanUri")] + #[serde(default)] + pub x509_san_uri: Option, + #[serde(rename = "x509SubjectCn")] + #[serde(default)] + pub x509_subject_cn: Option, + + /// Catchall for additional identity fields + #[cfg(feature = "catch-all-fields")] + #[cfg_attr(docsrs, doc(cfg(feature = "catch-all-fields")))] + #[serde(flatten)] + pub other: serde_json::Map, +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[cfg(feature = "vpc_lattice")] + fn example_vpc_lattice_v2_deserialize() { + let data = include_bytes!("../../fixtures/example-vpc-lattice-v2-request.json"); + let parsed: VpcLatticeRequestV2 = serde_json::from_slice(data).unwrap(); + + assert_eq!("/health", parsed.path.unwrap()); + assert_eq!("GET", parsed.method); + assert_eq!( + "curl/7.68.0", + parsed.headers.get_all("user-agent").iter().next().unwrap() + ); + + // headers including testing multi-values + let header_multi = parsed.headers.get_all("multi"); + assert_eq!("x", header_multi.iter().next().unwrap()); + assert_eq!("y", header_multi.iter().nth(1).unwrap()); + + // query string including testing multi-values + assert_eq!("prod", parsed.query_string_parameters.first("state").unwrap()); + let query_multi = parsed.query_string_parameters.all("multi").unwrap(); + assert_eq!(&"a", query_multi.first().unwrap()); + assert_eq!(&"DEF", query_multi.get(1).unwrap()); + assert_eq!(&"g", query_multi.get(2).unwrap()); + + assert!(parsed.body.is_none()); + assert!(!parsed.is_base64_encoded); + + // nested structure testing + assert_eq!( + "arn:aws:vpc-lattice:ap-southeast-2:123456789012:service/svc-0a40eebed65f8d69c", + parsed.request_context.service_arn.unwrap() + ); + assert_eq!( + "arn:aws:vpc-lattice:ap-southeast-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a", + parsed.request_context.service_network_arn.unwrap() + ); + assert_eq!( + "arn:aws:vpc-lattice:ap-southeast-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + parsed.request_context.target_group_arn.unwrap() + ); + assert_eq!("ap-southeast-2", parsed.request_context.region.unwrap()); + assert_eq!("1724875399456789", parsed.request_context.time_epoch.unwrap()); + + let context = parsed.request_context.identity.as_ref().unwrap(); + + // identity + assert_eq!( + "arn:aws:ec2:ap-southeast-2:123456789012:vpc/vpc-0b8276c84697e7339", + context.clone().source_vpc_arn.unwrap() + ); + assert_eq!("AWS_IAM", context.clone().identity_type.unwrap()); + assert_eq!( + "arn:aws:iam::123456789012:role/service-role/HealthChecker", + context.clone().principal.unwrap() + ); + assert_eq!("o-50dc6c495c0c9188", context.clone().principal_org_id.unwrap()); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn example_vpc_lattice_v2_serde_round_trip() { + // our basic example has instances of multi-value headers and multi-value parameters + // so this test covers both those serialization edge cases + let data = include_bytes!("../../fixtures/example-vpc-lattice-v2-request.json"); + let parsed: VpcLatticeRequestV2 = serde_json::from_slice(data).unwrap(); + let output: String = serde_json::to_string(&parsed).unwrap(); + let reparsed: VpcLatticeRequestV2 = serde_json::from_slice(output.as_bytes()).unwrap(); + assert_eq!(parsed, reparsed); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn example_vpc_lattice_v2_serde_round_trip_base64_body() { + let data = include_bytes!("../../fixtures/example-vpc-lattice-v2-request-base64.json"); + let parsed: VpcLatticeRequestV2 = serde_json::from_slice(data).unwrap(); + let output: String = serde_json::to_string(&parsed).unwrap(); + let reparsed: VpcLatticeRequestV2 = serde_json::from_slice(output.as_bytes()).unwrap(); + assert_eq!(parsed, reparsed); + } + + #[test] + #[cfg(feature = "vpc_lattice")] + fn example_vpc_lattice_v2_serde_round_trip_role_anywhere() { + let data = include_bytes!("../../fixtures/example-vpc-lattice-v2-request-roles-anywhere.json"); + let parsed: VpcLatticeRequestV2 = serde_json::from_slice(data).unwrap(); + let output: String = serde_json::to_string(&parsed).unwrap(); + let reparsed: VpcLatticeRequestV2 = serde_json::from_slice(output.as_bytes()).unwrap(); + assert_eq!(parsed, reparsed); + } +} diff --git a/lambda-events/src/fixtures/example-vpc-lattice-response.json b/lambda-events/src/fixtures/example-vpc-lattice-response.json new file mode 100644 index 00000000..70a95cbf --- /dev/null +++ b/lambda-events/src/fixtures/example-vpc-lattice-response.json @@ -0,0 +1,11 @@ +{ + "statusCode": 200, + "statusDescription": "200 OK", + "headers": { + "content-type": "application/json", + "x-request-id": "req-123456789", + "cache-control": "no-cache" + }, + "body": "{\"orderId\": \"order-789\", \"status\": \"created\", \"message\": \"Order successfully created\"}", + "isBase64Encoded": false +} \ No newline at end of file diff --git a/lambda-events/src/fixtures/example-vpc-lattice-v1-request.json b/lambda-events/src/fixtures/example-vpc-lattice-v1-request.json new file mode 100644 index 00000000..bda2de8f --- /dev/null +++ b/lambda-events/src/fixtures/example-vpc-lattice-v1-request.json @@ -0,0 +1,19 @@ +{ + "raw_path": "/api/product", + "method": "POST", + "headers": { + "accept": "*/*", + "user-agent": "curl/7.68.0", + "x-forwarded-for": "10.0.2.100", + "authorization": "Bearer abc123def456", + "multi": "abcd, DEF" + }, + "query_string_parameters": { + "category": "electronics", + "sort": "price", + "limit": "10", + "tags": "tv" + }, + "body": "{\"id\": 5, \"description\": \"TV\"}", + "is_base64_encoded": false +} \ No newline at end of file diff --git a/lambda-events/src/fixtures/example-vpc-lattice-v2-request-base64.json b/lambda-events/src/fixtures/example-vpc-lattice-v2-request-base64.json new file mode 100644 index 00000000..8cf238b6 --- /dev/null +++ b/lambda-events/src/fixtures/example-vpc-lattice-v2-request-base64.json @@ -0,0 +1,29 @@ +{ + "version": "2.0", + "path": "/api/files/upload", + "method": "POST", + "headers": { + "content-type": ["multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"], + "content-encoding": ["gzip"], + "content-length": ["1024"], + "x-forwarded-for": ["10.0.1.45"] + }, + "queryStringParameters": { + "uploadType": ["profile-image"] + }, + "body": "H4sIAAAAAAAAA+3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA", + "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:user/john.developer", + "principalOrgID": "o-50dc6c495c0c9188" + }, + "region": "ap-southeast-2", + "timeEpoch": "1724875299234567" + } +} \ No newline at end of file diff --git a/lambda-events/src/fixtures/example-vpc-lattice-v2-request-roles-anywhere.json b/lambda-events/src/fixtures/example-vpc-lattice-v2-request-roles-anywhere.json new file mode 100644 index 00000000..f4d1dbb4 --- /dev/null +++ b/lambda-events/src/fixtures/example-vpc-lattice-v2-request-roles-anywhere.json @@ -0,0 +1,37 @@ +{ + "version": "2.0", + "path": "/api/external/data-sync", + "method": "POST", + "headers": { + "content-type": ["application/json"], + "user-agent": ["ExternalDataSync/2.1.0"], + "x-forwarded-for": ["203.45.67.89"], + "x-client-cert": ["present"], + "authorization": ["X509-Cert"] + }, + "queryStringParameters": { + "sync-type": ["incremental"], + "validate": ["true"] + }, + "body": "{\"timestamp\": \"2025-08-28T14:30:00Z\", \"records\": [{\"id\": \"ext-001\", \"data\": \"sample-data\"}], \"source\": \"external-partner-system\"}", + "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/ExternalPartnerAccessRole", + "principalOrgID": "o-50dc6c495c0c9188", + "sessionName": "external-data-sync-session", + "x509IssuerOu": "Engineering Department", + "x509SanDns": "partner-system.external-company.com", + "x509SanNameCn": "Data Sync Service", + "x509SanUri": "https://partner-system.external-company.com/sync", + "x509SubjectCn": "external-partner-data-sync.external-company.com" + }, + "region": "ap-southeast-2", + "timeEpoch": "1724875199177430" + } +} diff --git a/lambda-events/src/fixtures/example-vpc-lattice-v2-request.json b/lambda-events/src/fixtures/example-vpc-lattice-v2-request.json new file mode 100644 index 00000000..6901a37b --- /dev/null +++ b/lambda-events/src/fixtures/example-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": null, + "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-events/src/lib.rs b/lambda-events/src/lib.rs index d35dbd76..38067099 100644 --- a/lambda-events/src/lib.rs +++ b/lambda-events/src/lib.rs @@ -226,3 +226,8 @@ pub use event::documentdb; #[cfg(feature = "eventbridge")] #[cfg_attr(docsrs, doc(cfg(feature = "eventbridge")))] pub use event::eventbridge; + +/// AWS Lambda event definitions for VPC lattice. +#[cfg(feature = "vpc_lattice")] +#[cfg_attr(docsrs, doc(cfg(feature = "vpc_lattice")))] +pub use event::vpc_lattice;