diff --git a/Sources/AWSLambdaEvents/APIGateway+V2.swift b/Sources/AWSLambdaEvents/APIGateway+V2.swift index 767bd1b..42cf355 100644 --- a/Sources/AWSLambdaEvents/APIGateway+V2.swift +++ b/Sources/AWSLambdaEvents/APIGateway+V2.swift @@ -15,7 +15,7 @@ import HTTPTypes /// `APIGatewayV2Request` contains data coming from the new HTTP API Gateway. -public struct APIGatewayV2Request: Codable, Sendable { +public struct APIGatewayV2Request: Encodable, Sendable { /// `Context` contains information to identify the AWS account and resources invoking the Lambda function. public struct Context: Codable, Sendable { public struct HTTP: Codable, Sendable { @@ -96,13 +96,13 @@ public struct APIGatewayV2Request: Codable, Sendable { public let rawPath: String public let rawQueryString: String - public let cookies: [String]? + public let cookies: [String] public let headers: HTTPHeaders - public let queryStringParameters: [String: String]? - public let pathParameters: [String: String]? + public let queryStringParameters: [String: String] + public let pathParameters: [String: String] public let context: Context - public let stageVariables: [String: String]? + public let stageVariables: [String: String] public let body: String? public let isBase64Encoded: Bool @@ -147,3 +147,26 @@ public struct APIGatewayV2Response: Codable, Sendable { self.cookies = cookies } } + +extension APIGatewayV2Request: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.version = try container.decode(String.self, forKey: .version) + self.routeKey = try container.decode(String.self, forKey: .routeKey) + self.rawPath = try container.decode(String.self, forKey: .rawPath) + self.rawQueryString = try container.decode(String.self, forKey: .rawQueryString) + + self.cookies = try container.decodeIfPresent([String].self, forKey: .cookies) ?? [] + self.headers = try container.decodeIfPresent(HTTPHeaders.self, forKey: .headers) ?? HTTPHeaders() + self.queryStringParameters = + try container.decodeIfPresent([String: String].self, forKey: .queryStringParameters) ?? [:] + self.pathParameters = try container.decodeIfPresent([String: String].self, forKey: .pathParameters) ?? [:] + + self.context = try container.decode(Context.self, forKey: .context) + self.stageVariables = try container.decodeIfPresent([String: String].self, forKey: .stageVariables) ?? [:] + + self.body = try container.decodeIfPresent(String.self, forKey: .body) + self.isBase64Encoded = try container.decode(Bool.self, forKey: .isBase64Encoded) + } +} diff --git a/Sources/AWSLambdaEvents/APIGateway.swift b/Sources/AWSLambdaEvents/APIGateway.swift index e2a18f1..2ca51c3 100644 --- a/Sources/AWSLambdaEvents/APIGateway.swift +++ b/Sources/AWSLambdaEvents/APIGateway.swift @@ -24,7 +24,7 @@ import Foundation // https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html /// `APIGatewayRequest` contains data coming from the API Gateway. -public struct APIGatewayRequest: Codable, Sendable { +public struct APIGatewayRequest: Encodable, Sendable { public struct Context: Codable, Sendable { public struct Identity: Codable, Sendable { public let cognitoIdentityPoolId: String? @@ -64,12 +64,12 @@ public struct APIGatewayRequest: Codable, Sendable { public let path: String public let httpMethod: HTTPRequest.Method - public let queryStringParameters: [String: String]? - public let multiValueQueryStringParameters: [String: [String]]? + public let queryStringParameters: [String: String] + public let multiValueQueryStringParameters: [String: [String]] public let headers: HTTPHeaders public let multiValueHeaders: HTTPMultiValueHeaders - public let pathParameters: [String: String]? - public let stageVariables: [String: String]? + public let pathParameters: [String: String] + public let stageVariables: [String: String] public let requestContext: Context public let body: String? @@ -99,3 +99,28 @@ public struct APIGatewayResponse: Codable, Sendable { self.isBase64Encoded = isBase64Encoded } } + +extension APIGatewayRequest: Decodable { + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.resource = try container.decode(String.self, forKey: .resource) + self.path = try container.decode(String.self, forKey: .path) + self.httpMethod = try container.decode(HTTPRequest.Method.self, forKey: .httpMethod) + + self.queryStringParameters = + try container.decodeIfPresent([String: String].self, forKey: .queryStringParameters) ?? [:] + self.multiValueQueryStringParameters = + try container.decodeIfPresent([String: [String]].self, forKey: .multiValueQueryStringParameters) ?? [:] + self.headers = try container.decodeIfPresent(HTTPHeaders.self, forKey: .headers) ?? HTTPHeaders() + self.multiValueHeaders = + try container.decodeIfPresent(HTTPMultiValueHeaders.self, forKey: .multiValueHeaders) + ?? HTTPMultiValueHeaders() + self.pathParameters = try container.decodeIfPresent([String: String].self, forKey: .pathParameters) ?? [:] + self.stageVariables = try container.decodeIfPresent([String: String].self, forKey: .stageVariables) ?? [:] + + self.requestContext = try container.decode(Context.self, forKey: .requestContext) + self.body = try container.decodeIfPresent(String.self, forKey: .body) + self.isBase64Encoded = try container.decode(Bool.self, forKey: .isBase64Encoded) + } +} diff --git a/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift b/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift index aa6d208..f3af5ab 100644 --- a/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift +++ b/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift @@ -73,6 +73,46 @@ class APIGatewayV2Tests: XCTestCase { } """ + static let exampleGetEventBodyNilHeaders = """ + { + "routeKey":"GET /hello", + "version":"2.0", + "rawPath":"/hello", + "requestContext":{ + "timeEpoch":1587750461466, + "domainPrefix":"hello", + "authorizer":{ + "jwt":{ + "scopes":[ + "hello" + ], + "claims":{ + "aud":"customers", + "iss":"https://hello.test.com/", + "iat":"1587749276", + "exp":"1587756476" + } + } + }, + "accountId":"0123456789", + "stage":"$default", + "domainName":"hello.test.com", + "apiId":"pb5dg6g3rg", + "requestId":"LgLpnibOFiAEPCA=", + "http":{ + "path":"/hello", + "userAgent":"Paw/3.1.10 (Macintosh; OS X/10.15.4) GCDHTTPRequest", + "method":"GET", + "protocol":"HTTP/1.1", + "sourceIp":"91.64.117.86" + }, + "time":"24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded":false, + "rawQueryString":"foo=bar" + } + """ + static let fullExamplePayload = """ { "version": "2.0", @@ -156,7 +196,7 @@ class APIGatewayV2Tests: XCTestCase { XCTAssertEqual(req?.rawPath, "/hello") XCTAssertEqual(req?.context.http.method, .get) - XCTAssertEqual(req?.queryStringParameters?.count, 1) + XCTAssertEqual(req?.queryStringParameters.count, 1) XCTAssertEqual(req?.rawQueryString, "foo=bar") XCTAssertEqual(req?.headers.count, 8) XCTAssertEqual(req?.context.authorizer?.jwt?.claims?["aud"], "customers") @@ -176,4 +216,9 @@ class APIGatewayV2Tests: XCTestCase { XCTAssertEqual(clientCert?.validity.notBefore, "May 28 12:30:02 2019 GMT") XCTAssertEqual(clientCert?.validity.notAfter, "Aug 5 09:36:04 2021 GMT") } + + func testDecodingNilCollections() { + let data = APIGatewayV2Tests.exampleGetEventBodyNilHeaders.data(using: .utf8)! + XCTAssertNoThrow(_ = try JSONDecoder().decode(APIGatewayV2Request.self, from: data)) + } } diff --git a/Tests/AWSLambdaEventsTests/APIGatewayTests.swift b/Tests/AWSLambdaEventsTests/APIGatewayTests.swift index 517e4b1..2f55dfe 100644 --- a/Tests/AWSLambdaEventsTests/APIGatewayTests.swift +++ b/Tests/AWSLambdaEventsTests/APIGatewayTests.swift @@ -34,6 +34,10 @@ class APIGatewayTests: XCTestCase { {"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} """ + static let postEventBodyNilHeaders = """ + {"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} + """ + // MARK: - Request - // MARK: Decoding @@ -108,4 +112,9 @@ class APIGatewayTests: XCTestCase { XCTAssertEqual(json?.isBase64Encoded, resp.isBase64Encoded) XCTAssertEqual(json?.headers?["Server"], "Test") } + + func testDecodingNilCollections() { + let data = APIGatewayTests.postEventBodyNilHeaders.data(using: .utf8)! + XCTAssertNoThrow(_ = try JSONDecoder().decode(APIGatewayRequest.self, from: data)) + } }