Skip to content

Commit f47d448

Browse files
authored
Implement custom deserializer for LambdaRequest (#666)
This deserializer gives us full control over the error message that we return for invalid payloads. The default message that Serde returns is usually very confusing, and it's been reported many times as something people don't understand. This code is a copy of the code that Serde generates when it expands the Deserialize macro. Signed-off-by: David Calavera <[email protected]>
1 parent aa80e74 commit f47d448

File tree

8 files changed

+238
-58
lines changed

8 files changed

+238
-58
lines changed

lambda-events/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "aws_lambda_events"
3-
version = "0.10.0"
3+
version = "0.11.0"
44
description = "AWS Lambda event definitions"
55
authors = [
66
"Christian Legnitto <[email protected]>",

lambda-events/src/event/alb/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
99
/// `AlbTargetGroupRequest` contains data originating from the ALB Lambda target group integration
1010
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
1111
#[serde(rename_all = "camelCase")]
12+
#[serde(deny_unknown_fields)]
1213
pub struct AlbTargetGroupRequest {
1314
#[serde(with = "http_method")]
1415
pub http_method: Method,

lambda-events/src/event/apigw/mod.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use std::collections::HashMap;
1313
/// `ApiGatewayProxyRequest` contains data coming from the API Gateway proxy
1414
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
1515
#[serde(rename_all = "camelCase")]
16+
#[serde(deny_unknown_fields)]
1617
pub struct ApiGatewayProxyRequest<T1 = Value>
1718
where
1819
T1: DeserializeOwned,
@@ -118,12 +119,25 @@ where
118119
/// `ApiGatewayV2httpRequest` contains data coming from the new HTTP API Gateway
119120
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
120121
#[serde(rename_all = "camelCase")]
122+
#[serde(deny_unknown_fields)]
121123
pub struct ApiGatewayV2httpRequest {
124+
#[serde(default, rename = "type")]
125+
pub kind: Option<String>,
126+
#[serde(default)]
127+
pub method_arn: Option<String>,
128+
#[serde(with = "http_method", default = "default_http_method")]
129+
pub http_method: Method,
130+
#[serde(default)]
131+
pub identity_source: Option<String>,
132+
#[serde(default)]
133+
pub authorization_token: Option<String>,
134+
#[serde(default)]
135+
pub resource: Option<String>,
122136
#[serde(default)]
123137
pub version: Option<String>,
124138
#[serde(default)]
125139
pub route_key: Option<String>,
126-
#[serde(default)]
140+
#[serde(default, alias = "path")]
127141
pub raw_path: Option<String>,
128142
#[serde(default)]
129143
pub raw_query_string: Option<String>,
@@ -319,6 +333,7 @@ pub struct ApiGatewayRequestIdentity {
319333
/// `ApiGatewayWebsocketProxyRequest` contains data coming from the API Gateway proxy
320334
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
321335
#[serde(rename_all = "camelCase")]
336+
#[serde(deny_unknown_fields)]
322337
pub struct ApiGatewayWebsocketProxyRequest<T1 = Value, T2 = Value>
323338
where
324339
T1: DeserializeOwned,
@@ -747,6 +762,10 @@ pub struct IamPolicyStatement {
747762
pub resource: Vec<String>,
748763
}
749764

765+
fn default_http_method() -> Method {
766+
Method::GET
767+
}
768+
750769
#[cfg(test)]
751770
mod test {
752771
use super::*;
@@ -901,6 +920,8 @@ mod test {
901920
let output: String = serde_json::to_string(&parsed).unwrap();
902921
let reparsed: ApiGatewayV2httpRequest = serde_json::from_slice(output.as_bytes()).unwrap();
903922
assert_eq!(parsed, reparsed);
923+
assert_eq!("REQUEST", parsed.kind.unwrap());
924+
assert_eq!(Method::GET, parsed.http_method);
904925
}
905926

906927
#[test]
Lines changed: 92 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,95 @@
11
{
22
"resource": "/{proxy+}",
3-
"path": "/hello/world",
4-
"httpMethod": "POST",
5-
"headers": {
6-
"Accept": "*/*",
7-
"Accept-Encoding": "gzip, deflate",
8-
"cache-control": "no-cache",
9-
"CloudFront-Forwarded-Proto": "https",
10-
"CloudFront-Is-Desktop-Viewer": "true",
11-
"CloudFront-Is-Mobile-Viewer": "false",
12-
"CloudFront-Is-SmartTV-Viewer": "false",
13-
"CloudFront-Is-Tablet-Viewer": "false",
14-
"CloudFront-Viewer-Country": "US",
15-
"Content-Type": "application/json",
16-
"headerName": "headerValue",
17-
"Host": "gy415nuibc.execute-api.us-east-1.amazonaws.com",
18-
"Postman-Token": "9f583ef0-ed83-4a38-aef3-eb9ce3f7a57f",
19-
"User-Agent": "PostmanRuntime/2.4.5",
20-
"Via": "1.1 d98420743a69852491bbdea73f7680bd.cloudfront.net (CloudFront)",
21-
"X-Amz-Cf-Id": "pn-PWIJc6thYnZm5P0NMgOUglL1DYtl0gdeJky8tqsg8iS_sgsKD1A==",
22-
"X-Forwarded-For": "54.240.196.186, 54.182.214.83",
23-
"X-Forwarded-Port": "443",
24-
"X-Forwarded-Proto": "https"
25-
},
26-
"multiValueHeaders": {
27-
"Accept": ["*/*"],
28-
"Accept-Encoding": ["gzip, deflate"],
29-
"cache-control": ["no-cache"],
30-
"CloudFront-Forwarded-Proto": ["https"],
31-
"CloudFront-Is-Desktop-Viewer": ["true"],
32-
"CloudFront-Is-Mobile-Viewer": ["false"],
33-
"CloudFront-Is-SmartTV-Viewer": ["false"],
34-
"CloudFront-Is-Tablet-Viewer": ["false"],
35-
"CloudFront-Viewer-Country": ["US"],
36-
"Content-Type": ["application/json"],
37-
"headerName": ["headerValue"],
38-
"Host": ["gy415nuibc.execute-api.us-east-1.amazonaws.com"],
39-
"Postman-Token": ["9f583ef0-ed83-4a38-aef3-eb9ce3f7a57f"],
40-
"User-Agent": ["PostmanRuntime/2.4.5"],
41-
"Via": ["1.1 d98420743a69852491bbdea73f7680bd.cloudfront.net (CloudFront)"],
42-
"X-Amz-Cf-Id": ["pn-PWIJc6thYnZm5P0NMgOUglL1DYtl0gdeJky8tqsg8iS_sgsKD1A=="],
43-
"X-Forwarded-For": ["54.240.196.186, 54.182.214.83"],
44-
"X-Forwarded-Port": ["443"],
45-
"X-Forwarded-Proto": ["https"]
46-
},
3+
"path": "/hello/world",
4+
"httpMethod": "POST",
5+
"headers": {
6+
"Accept": "*/*",
7+
"Accept-Encoding": "gzip, deflate",
8+
"cache-control": "no-cache",
9+
"CloudFront-Forwarded-Proto": "https",
10+
"CloudFront-Is-Desktop-Viewer": "true",
11+
"CloudFront-Is-Mobile-Viewer": "false",
12+
"CloudFront-Is-SmartTV-Viewer": "false",
13+
"CloudFront-Is-Tablet-Viewer": "false",
14+
"CloudFront-Viewer-Country": "US",
15+
"Content-Type": "application/json",
16+
"headerName": "headerValue",
17+
"Host": "gy415nuibc.execute-api.us-east-1.amazonaws.com",
18+
"Postman-Token": "9f583ef0-ed83-4a38-aef3-eb9ce3f7a57f",
19+
"User-Agent": "PostmanRuntime/2.4.5",
20+
"Via": "1.1 d98420743a69852491bbdea73f7680bd.cloudfront.net (CloudFront)",
21+
"X-Amz-Cf-Id": "pn-PWIJc6thYnZm5P0NMgOUglL1DYtl0gdeJky8tqsg8iS_sgsKD1A==",
22+
"X-Forwarded-For": "54.240.196.186, 54.182.214.83",
23+
"X-Forwarded-Port": "443",
24+
"X-Forwarded-Proto": "https"
25+
},
26+
"multiValueHeaders": {
27+
"Accept": [
28+
"*/*"
29+
],
30+
"Accept-Encoding": [
31+
"gzip, deflate"
32+
],
33+
"cache-control": [
34+
"no-cache"
35+
],
36+
"CloudFront-Forwarded-Proto": [
37+
"https"
38+
],
39+
"CloudFront-Is-Desktop-Viewer": [
40+
"true"
41+
],
42+
"CloudFront-Is-Mobile-Viewer": [
43+
"false"
44+
],
45+
"CloudFront-Is-SmartTV-Viewer": [
46+
"false"
47+
],
48+
"CloudFront-Is-Tablet-Viewer": [
49+
"false"
50+
],
51+
"CloudFront-Viewer-Country": [
52+
"US"
53+
],
54+
"Content-Type": [
55+
"application/json"
56+
],
57+
"headerName": [
58+
"headerValue"
59+
],
60+
"Host": [
61+
"gy415nuibc.execute-api.us-east-1.amazonaws.com"
62+
],
63+
"Postman-Token": [
64+
"9f583ef0-ed83-4a38-aef3-eb9ce3f7a57f"
65+
],
66+
"User-Agent": [
67+
"PostmanRuntime/2.4.5"
68+
],
69+
"Via": [
70+
"1.1 d98420743a69852491bbdea73f7680bd.cloudfront.net (CloudFront)"
71+
],
72+
"X-Amz-Cf-Id": [
73+
"pn-PWIJc6thYnZm5P0NMgOUglL1DYtl0gdeJky8tqsg8iS_sgsKD1A=="
74+
],
75+
"X-Forwarded-For": [
76+
"54.240.196.186, 54.182.214.83"
77+
],
78+
"X-Forwarded-Port": [
79+
"443"
80+
],
81+
"X-Forwarded-Proto": [
82+
"https"
83+
]
84+
},
4785
"queryStringParameters": {
4886
"name": "me"
49-
},
50-
"multiValueQueryStringParameters": {
51-
"name": ["me"]
52-
},
87+
},
88+
"multiValueQueryStringParameters": {
89+
"name": [
90+
"me"
91+
]
92+
},
5393
"pathParameters": {
5494
"proxy": "hello/world"
5595
},
@@ -70,9 +110,9 @@
70110
"accountId": "theAccountId",
71111
"cognitoIdentityId": "theCognitoIdentityId",
72112
"caller": "theCaller",
73-
"apiKey": "theApiKey",
74-
"apiKeyId": "theApiKeyId",
75-
"accessKey": "ANEXAMPLEOFACCESSKEY",
113+
"apiKey": "theApiKey",
114+
"apiKeyId": "theApiKeyId",
115+
"accessKey": "ANEXAMPLEOFACCESSKEY",
76116
"sourceIp": "192.168.196.186",
77117
"cognitoAuthenticationType": "theCognitoAuthenticationType",
78118
"cognitoAuthenticationProvider": "theCognitoAuthenticationProvider",
@@ -92,5 +132,4 @@
92132
"apiId": "gy415nuibc"
93133
},
94134
"body": "{\r\n\t\"a\": 1\r\n}"
95-
}
96-
135+
}

lambda-http/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ percent-encoding = "2.2"
4040

4141
[dependencies.aws_lambda_events]
4242
path = "../lambda-events"
43-
version = "0.10.0"
43+
version = "0.11.0"
4444
default-features = false
4545
features = ["alb", "apigw"]
4646

lambda-http/src/deserializer.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use crate::request::LambdaRequest;
2+
use aws_lambda_events::{
3+
alb::AlbTargetGroupRequest,
4+
apigw::{ApiGatewayProxyRequest, ApiGatewayV2httpRequest, ApiGatewayWebsocketProxyRequest},
5+
};
6+
use serde::{de::Error, Deserialize};
7+
8+
const ERROR_CONTEXT: &str = "this function expects a JSON payload from Amazon API Gateway, Amazon Elastic Load Balancer, or AWS Lambda Function URLs, but the data doesn't match any of those services' events";
9+
10+
impl<'de> Deserialize<'de> for LambdaRequest {
11+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
12+
where
13+
D: serde::Deserializer<'de>,
14+
{
15+
let content = match serde::__private::de::Content::deserialize(deserializer) {
16+
Ok(content) => content,
17+
Err(err) => return Err(err),
18+
};
19+
#[cfg(feature = "apigw_rest")]
20+
if let Ok(res) =
21+
ApiGatewayProxyRequest::deserialize(serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content))
22+
{
23+
return Ok(LambdaRequest::ApiGatewayV1(res));
24+
}
25+
#[cfg(feature = "apigw_http")]
26+
if let Ok(res) = ApiGatewayV2httpRequest::deserialize(
27+
serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content),
28+
) {
29+
return Ok(LambdaRequest::ApiGatewayV2(res));
30+
}
31+
#[cfg(feature = "alb")]
32+
if let Ok(res) =
33+
AlbTargetGroupRequest::deserialize(serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content))
34+
{
35+
return Ok(LambdaRequest::Alb(res));
36+
}
37+
#[cfg(feature = "apigw_websockets")]
38+
if let Ok(res) = ApiGatewayWebsocketProxyRequest::deserialize(serde::__private::de::ContentRefDeserializer::<
39+
D::Error,
40+
>::new(&content))
41+
{
42+
return Ok(LambdaRequest::WebSocket(res));
43+
}
44+
45+
Err(Error::custom(ERROR_CONTEXT))
46+
}
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
53+
#[test]
54+
fn test_deserialize_apigw_rest() {
55+
let data = include_bytes!("../../lambda-events/src/fixtures/example-apigw-request.json");
56+
57+
let req: LambdaRequest = serde_json::from_slice(data).expect("failed to deserialze apigw rest data");
58+
match req {
59+
LambdaRequest::ApiGatewayV1(req) => {
60+
assert_eq!("12345678912", req.request_context.account_id.unwrap());
61+
}
62+
other => panic!("unexpected request variant: {:?}", other),
63+
}
64+
}
65+
66+
#[test]
67+
fn test_deserialize_apigw_http() {
68+
let data = include_bytes!("../../lambda-events/src/fixtures/example-apigw-v2-request-iam.json");
69+
70+
let req: LambdaRequest = serde_json::from_slice(data).expect("failed to deserialze apigw http data");
71+
match req {
72+
LambdaRequest::ApiGatewayV2(req) => {
73+
assert_eq!("123456789012", req.request_context.account_id.unwrap());
74+
}
75+
other => panic!("unexpected request variant: {:?}", other),
76+
}
77+
}
78+
79+
#[test]
80+
fn test_deserialize_alb() {
81+
let data = include_bytes!(
82+
"../../lambda-events/src/fixtures/example-alb-lambda-target-request-multivalue-headers.json"
83+
);
84+
85+
let req: LambdaRequest = serde_json::from_slice(data).expect("failed to deserialze alb rest data");
86+
match req {
87+
LambdaRequest::Alb(req) => {
88+
assert_eq!(
89+
"arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/lambda-target/abcdefgh",
90+
req.request_context.elb.target_group_arn.unwrap()
91+
);
92+
}
93+
other => panic!("unexpected request variant: {:?}", other),
94+
}
95+
}
96+
97+
#[test]
98+
fn test_deserialize_apigw_websocket() {
99+
let data =
100+
include_bytes!("../../lambda-events/src/fixtures/example-apigw-websocket-request-without-method.json");
101+
102+
let req: LambdaRequest = serde_json::from_slice(data).expect("failed to deserialze apigw websocket data");
103+
match req {
104+
LambdaRequest::WebSocket(req) => {
105+
assert_eq!("CONNECT", req.request_context.event_type.unwrap());
106+
}
107+
other => panic!("unexpected request variant: {:?}", other),
108+
}
109+
}
110+
111+
#[test]
112+
fn test_deserialize_error() {
113+
let err = serde_json::from_str::<LambdaRequest>("{\"command\": \"hi\"}").unwrap_err();
114+
115+
assert_eq!(ERROR_CONTEXT, err.to_string());
116+
}
117+
}

lambda-http/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ pub use lambda_runtime::{self, service_fn, tower, Context, Error, Service};
7070
use request::RequestFuture;
7171
use response::ResponseFuture;
7272

73+
mod deserializer;
7374
pub mod ext;
7475
pub mod request;
7576
mod response;

lambda-http/src/request.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ use aws_lambda_events::apigw::{ApiGatewayWebsocketProxyRequest, ApiGatewayWebsoc
2020
use aws_lambda_events::{encodings::Body, query_map::QueryMap};
2121
use http::header::HeaderName;
2222
use http::{HeaderMap, HeaderValue};
23+
2324
use serde::{Deserialize, Serialize};
2425
use serde_json::error::Error as JsonError;
26+
2527
use std::future::Future;
2628
use std::pin::Pin;
2729
use std::{env, io::Read, mem};
@@ -33,8 +35,7 @@ use url::Url;
3335
/// This is not intended to be a type consumed by crate users directly. The order
3436
/// of the variants are notable. Serde will try to deserialize in this order.
3537
#[doc(hidden)]
36-
#[derive(Deserialize, Debug)]
37-
#[serde(untagged)]
38+
#[derive(Debug)]
3839
pub enum LambdaRequest {
3940
#[cfg(feature = "apigw_rest")]
4041
ApiGatewayV1(ApiGatewayProxyRequest),

0 commit comments

Comments
 (0)