Skip to content

Commit 334d865

Browse files
authoredDec 3, 2024
Add custom Decodable conformance (#80)
Add custom Decodable conformance. Closes #73 Duplicate of #76 created by @twistinside **Motivation:** Absent collections would fail to decode, when in reality it is possible to have empty headers, for example. This allows processing to continue in the context of the Lambda runtime where users can handle the collections as dictated by their code. **Modifications:** Custom Decodable conformance has been added to both API Gateway request objects to optionally decode absent collections to empty collections. Unit testa have been added for the same. **Result:** This will make the swift lambda runtime more accepting of input, allowing processing to proceed in the rare cases where collection fields would be absent in the JSON received by the runtime. This will unlock certain functionality in edge cases such as testing lambda code from API Gateway console with empty headers (the use case that prompted this change). This change will require users to update their code to remove optional handling for the collections involved, potentially also requiring extra logic to validate that the collections they've received aren't empty. --------- Authored-by: twistinside <twistinside@users.noreply.github.com>
1 parent 3c1161a commit 334d865

File tree

4 files changed

+113
-11
lines changed

4 files changed

+113
-11
lines changed
 

‎Sources/AWSLambdaEvents/APIGateway+V2.swift

+28-5
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import HTTPTypes
1616

1717
/// `APIGatewayV2Request` contains data coming from the new HTTP API Gateway.
18-
public struct APIGatewayV2Request: Codable, Sendable {
18+
public struct APIGatewayV2Request: Encodable, Sendable {
1919
/// `Context` contains information to identify the AWS account and resources invoking the Lambda function.
2020
public struct Context: Codable, Sendable {
2121
public struct HTTP: Codable, Sendable {
@@ -96,13 +96,13 @@ public struct APIGatewayV2Request: Codable, Sendable {
9696
public let rawPath: String
9797
public let rawQueryString: String
9898

99-
public let cookies: [String]?
99+
public let cookies: [String]
100100
public let headers: HTTPHeaders
101-
public let queryStringParameters: [String: String]?
102-
public let pathParameters: [String: String]?
101+
public let queryStringParameters: [String: String]
102+
public let pathParameters: [String: String]
103103

104104
public let context: Context
105-
public let stageVariables: [String: String]?
105+
public let stageVariables: [String: String]
106106

107107
public let body: String?
108108
public let isBase64Encoded: Bool
@@ -147,3 +147,26 @@ public struct APIGatewayV2Response: Codable, Sendable {
147147
self.cookies = cookies
148148
}
149149
}
150+
151+
extension APIGatewayV2Request: Decodable {
152+
public init(from decoder: Decoder) throws {
153+
let container = try decoder.container(keyedBy: CodingKeys.self)
154+
155+
self.version = try container.decode(String.self, forKey: .version)
156+
self.routeKey = try container.decode(String.self, forKey: .routeKey)
157+
self.rawPath = try container.decode(String.self, forKey: .rawPath)
158+
self.rawQueryString = try container.decode(String.self, forKey: .rawQueryString)
159+
160+
self.cookies = try container.decodeIfPresent([String].self, forKey: .cookies) ?? []
161+
self.headers = try container.decodeIfPresent(HTTPHeaders.self, forKey: .headers) ?? HTTPHeaders()
162+
self.queryStringParameters =
163+
try container.decodeIfPresent([String: String].self, forKey: .queryStringParameters) ?? [:]
164+
self.pathParameters = try container.decodeIfPresent([String: String].self, forKey: .pathParameters) ?? [:]
165+
166+
self.context = try container.decode(Context.self, forKey: .context)
167+
self.stageVariables = try container.decodeIfPresent([String: String].self, forKey: .stageVariables) ?? [:]
168+
169+
self.body = try container.decodeIfPresent(String.self, forKey: .body)
170+
self.isBase64Encoded = try container.decode(Bool.self, forKey: .isBase64Encoded)
171+
}
172+
}

‎Sources/AWSLambdaEvents/APIGateway.swift

+30-5
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import Foundation
2424
// https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
2525

2626
/// `APIGatewayRequest` contains data coming from the API Gateway.
27-
public struct APIGatewayRequest: Codable, Sendable {
27+
public struct APIGatewayRequest: Encodable, Sendable {
2828
public struct Context: Codable, Sendable {
2929
public struct Identity: Codable, Sendable {
3030
public let cognitoIdentityPoolId: String?
@@ -64,12 +64,12 @@ public struct APIGatewayRequest: Codable, Sendable {
6464
public let path: String
6565
public let httpMethod: HTTPRequest.Method
6666

67-
public let queryStringParameters: [String: String]?
68-
public let multiValueQueryStringParameters: [String: [String]]?
67+
public let queryStringParameters: [String: String]
68+
public let multiValueQueryStringParameters: [String: [String]]
6969
public let headers: HTTPHeaders
7070
public let multiValueHeaders: HTTPMultiValueHeaders
71-
public let pathParameters: [String: String]?
72-
public let stageVariables: [String: String]?
71+
public let pathParameters: [String: String]
72+
public let stageVariables: [String: String]
7373

7474
public let requestContext: Context
7575
public let body: String?
@@ -99,3 +99,28 @@ public struct APIGatewayResponse: Codable, Sendable {
9999
self.isBase64Encoded = isBase64Encoded
100100
}
101101
}
102+
103+
extension APIGatewayRequest: Decodable {
104+
public init(from decoder: any Decoder) throws {
105+
let container = try decoder.container(keyedBy: CodingKeys.self)
106+
107+
self.resource = try container.decode(String.self, forKey: .resource)
108+
self.path = try container.decode(String.self, forKey: .path)
109+
self.httpMethod = try container.decode(HTTPRequest.Method.self, forKey: .httpMethod)
110+
111+
self.queryStringParameters =
112+
try container.decodeIfPresent([String: String].self, forKey: .queryStringParameters) ?? [:]
113+
self.multiValueQueryStringParameters =
114+
try container.decodeIfPresent([String: [String]].self, forKey: .multiValueQueryStringParameters) ?? [:]
115+
self.headers = try container.decodeIfPresent(HTTPHeaders.self, forKey: .headers) ?? HTTPHeaders()
116+
self.multiValueHeaders =
117+
try container.decodeIfPresent(HTTPMultiValueHeaders.self, forKey: .multiValueHeaders)
118+
?? HTTPMultiValueHeaders()
119+
self.pathParameters = try container.decodeIfPresent([String: String].self, forKey: .pathParameters) ?? [:]
120+
self.stageVariables = try container.decodeIfPresent([String: String].self, forKey: .stageVariables) ?? [:]
121+
122+
self.requestContext = try container.decode(Context.self, forKey: .requestContext)
123+
self.body = try container.decodeIfPresent(String.self, forKey: .body)
124+
self.isBase64Encoded = try container.decode(Bool.self, forKey: .isBase64Encoded)
125+
}
126+
}

‎Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift

+46-1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,46 @@ class APIGatewayV2Tests: XCTestCase {
7373
}
7474
"""
7575

76+
static let exampleGetEventBodyNilHeaders = """
77+
{
78+
"routeKey":"GET /hello",
79+
"version":"2.0",
80+
"rawPath":"/hello",
81+
"requestContext":{
82+
"timeEpoch":1587750461466,
83+
"domainPrefix":"hello",
84+
"authorizer":{
85+
"jwt":{
86+
"scopes":[
87+
"hello"
88+
],
89+
"claims":{
90+
"aud":"customers",
91+
"iss":"https://hello.test.com/",
92+
"iat":"1587749276",
93+
"exp":"1587756476"
94+
}
95+
}
96+
},
97+
"accountId":"0123456789",
98+
"stage":"$default",
99+
"domainName":"hello.test.com",
100+
"apiId":"pb5dg6g3rg",
101+
"requestId":"LgLpnibOFiAEPCA=",
102+
"http":{
103+
"path":"/hello",
104+
"userAgent":"Paw/3.1.10 (Macintosh; OS X/10.15.4) GCDHTTPRequest",
105+
"method":"GET",
106+
"protocol":"HTTP/1.1",
107+
"sourceIp":"91.64.117.86"
108+
},
109+
"time":"24/Apr/2020:17:47:41 +0000"
110+
},
111+
"isBase64Encoded":false,
112+
"rawQueryString":"foo=bar"
113+
}
114+
"""
115+
76116
static let fullExamplePayload = """
77117
{
78118
"version": "2.0",
@@ -156,7 +196,7 @@ class APIGatewayV2Tests: XCTestCase {
156196

157197
XCTAssertEqual(req?.rawPath, "/hello")
158198
XCTAssertEqual(req?.context.http.method, .get)
159-
XCTAssertEqual(req?.queryStringParameters?.count, 1)
199+
XCTAssertEqual(req?.queryStringParameters.count, 1)
160200
XCTAssertEqual(req?.rawQueryString, "foo=bar")
161201
XCTAssertEqual(req?.headers.count, 8)
162202
XCTAssertEqual(req?.context.authorizer?.jwt?.claims?["aud"], "customers")
@@ -176,4 +216,9 @@ class APIGatewayV2Tests: XCTestCase {
176216
XCTAssertEqual(clientCert?.validity.notBefore, "May 28 12:30:02 2019 GMT")
177217
XCTAssertEqual(clientCert?.validity.notAfter, "Aug 5 09:36:04 2021 GMT")
178218
}
219+
220+
func testDecodingNilCollections() {
221+
let data = APIGatewayV2Tests.exampleGetEventBodyNilHeaders.data(using: .utf8)!
222+
XCTAssertNoThrow(_ = try JSONDecoder().decode(APIGatewayV2Request.self, from: data))
223+
}
179224
}

‎Tests/AWSLambdaEventsTests/APIGatewayTests.swift

+9
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ class APIGatewayTests: XCTestCase {
3434
{"httpMethod": "POST", "body": "{\\"title\\":\\"a todo\\"}", "resource": "/todos", "requestContext": {"resourceId": "123456", "apiId": "1234567890", "domainName": "1234567890.execute-api.us-east-1.amazonaws.com", "resourcePath": "/todos", "httpMethod": "POST", "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", "accountId": "123456789012", "stage": "test", "identity": {"apiKey": null, "userArn": null, "cognitoAuthenticationType": null, "caller": null, "userAgent": "Custom User Agent String", "user": null, "cognitoIdentityPoolId": null, "cognitoAuthenticationProvider": null, "sourceIp": "127.0.0.1", "accountId": null}, "extendedRequestId": null, "path": "/todos"}, "queryStringParameters": null, "multiValueQueryStringParameters": null, "headers": {"Host": "127.0.0.1:3000", "Connection": "keep-alive", "Content-Length": "18", "Pragma": "no-cache", "Cache-Control": "no-cache", "Accept": "text/plain, */*; q=0.01", "Origin": "http://todobackend.com", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.36 Safari/537.36 Edg/79.0.309.25", "Dnt": "1", "Content-Type": "application/json", "Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "cors", "Referer": "http://todobackend.com/specs/index.html?http://127.0.0.1:3000/todos", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "en-US,en;q=0.9", "X-Forwarded-Proto": "http", "X-Forwarded-Port": "3000"}, "multiValueHeaders": {"Host": ["127.0.0.1:3000"], "Connection": ["keep-alive"], "Content-Length": ["18"], "Pragma": ["no-cache"], "Cache-Control": ["no-cache"], "Accept": ["text/plain, */*; q=0.01"], "Origin": ["http://todobackend.com"], "User-Agent": ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.36 Safari/537.36 Edg/79.0.309.25"], "Dnt": ["1"], "Content-Type": ["application/json"], "Sec-Fetch-Site": ["cross-site"], "Sec-Fetch-Mode": ["cors"], "Referer": ["http://todobackend.com/specs/index.html?http://127.0.0.1:3000/todos"], "Accept-Encoding": ["gzip, deflate, br"], "Accept-Language": ["en-US,en;q=0.9"], "X-Forwarded-Proto": ["http"], "X-Forwarded-Port": ["3000"]}, "pathParameters": null, "stageVariables": null, "path": "/todos", "isBase64Encoded": false}
3535
"""
3636

37+
static let postEventBodyNilHeaders = """
38+
{"httpMethod": "POST", "body": "{\\"title\\":\\"a todo\\"}", "resource":"/todos", "requestContext": {"resourceId": "123456", "apiId": "1234567890", "domainName": "1234567890.execute-api.us-east-1.amazonaws.com", "resourcePath": "/todos", "httpMethod": "POST", "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", "accountId": "123456789012", "stage": "test", "identity": {"apiKey": null, "userArn": null,"cognitoAuthenticationType": null, "caller": null, "userAgent": "Custom User Agent String", "user": null, "cognitoIdentityPoolId": null, "cognitoAuthenticationProvider": null, "sourceIp": "127.0.0.1", "accountId": null}, "extendedRequestId": null, "path": "/todos"}, "multiValueHeaders": {"Host": ["127.0.0.1:3000"], "Connection": ["keep-alive"], "Content-Length": ["18"], "Pragma": ["no-cache"], "Cache-Control": ["no-cache"], "Accept": ["text/plain, */*; q=0.01"], "Origin": ["http://todobackend.com"], "User-Agent": ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.36 Safari/537.36 Edg/79.0.309.25"], "Dnt": ["1"], "Content-Type": ["application/json"], "Sec-Fetch-Site": ["cross-site"], "Sec-Fetch-Mode": ["cors"], "Referer": ["http://todobackend.com/specs/index.html?http://127.0.0.1:3000/todos"], "Accept-Encoding": ["gzip, deflate, br"], "Accept-Language": ["en-US,en;q=0.9"], "X-Forwarded-Proto": ["http"], "X-Forwarded-Port": ["3000"]}, "path": "/todos", "isBase64Encoded": false}
39+
"""
40+
3741
// MARK: - Request -
3842

3943
// MARK: Decoding
@@ -108,4 +112,9 @@ class APIGatewayTests: XCTestCase {
108112
XCTAssertEqual(json?.isBase64Encoded, resp.isBase64Encoded)
109113
XCTAssertEqual(json?.headers?["Server"], "Test")
110114
}
115+
116+
func testDecodingNilCollections() {
117+
let data = APIGatewayTests.postEventBodyNilHeaders.data(using: .utf8)!
118+
XCTAssertNoThrow(_ = try JSONDecoder().decode(APIGatewayRequest.self, from: data))
119+
}
111120
}

0 commit comments

Comments
 (0)
Please sign in to comment.