From 9c13ba5e44a765a93949515738b5a36e822c90d8 Mon Sep 17 00:00:00 2001 From: Natan Rolnik Date: Tue, 5 Aug 2025 23:41:05 +0300 Subject: [PATCH 1/4] Add Codable helpers for FunctionURL, APIGatewayV2, and SQS --- .../Codable Helpers/APIGatewayV2+Decode.swift | 48 +++++++++++++++++++ .../Codable Helpers/APIGatewayV2+Encode.swift | 41 ++++++++++++++++ .../Codable Helpers/FunctionURL+Decode.swift | 48 +++++++++++++++++++ .../Codable Helpers/FunctionURL+Encode.swift | 41 ++++++++++++++++ .../Codable Helpers/SQS+Decode.swift | 41 ++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Decode.swift create mode 100644 Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Encode.swift create mode 100644 Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Decode.swift create mode 100644 Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Encode.swift create mode 100644 Sources/AWSLambdaEvents/Codable Helpers/SQS+Decode.swift diff --git a/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Decode.swift b/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Decode.swift new file mode 100644 index 0000000..f72178f --- /dev/null +++ b/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Decode.swift @@ -0,0 +1,48 @@ +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +import HTTPTypes + +public extension APIGatewayV2Request { + /// Decodes the body of the request into a `Data` object. + /// + /// - Returns: The decoded body as `Data` or `nil` if the body is empty. + func decodeBody() throws -> Data? { + guard let body else { return nil } + + if isBase64Encoded, + let base64Decoded = Data(base64Encoded: body) { + return base64Decoded + } + + return body.data(using: .utf8) + } + + /// Decodes the body of the request into a decodable object. When the + /// body is empty, an error is thrown. + /// + /// - Parameters: + /// - type: The type to decode the body into. + /// - decoder: The decoder to use. Defaults to `JSONDecoder()`. + /// + /// - Returns: The decoded body as `T`. + /// - Throws: An error if the body cannot be decoded. + func decodeBody( + _ type: T.Type, + using decoder: JSONDecoder = JSONDecoder() + ) throws -> T where T: Decodable { + let bodyData = body?.data(using: .utf8) ?? Data() + + var requestData = bodyData + + if isBase64Encoded, + let base64Decoded = Data(base64Encoded: requestData) { + requestData = base64Decoded + } + + return try decoder.decode(T.self, from: requestData) + } +} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Encode.swift b/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Encode.swift new file mode 100644 index 0000000..19c429b --- /dev/null +++ b/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Encode.swift @@ -0,0 +1,41 @@ +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +import HTTPTypes + +public extension APIGatewayV2Response { + /// Encodes a given encodable object into a `APIGatewayV2Response` object. + /// + /// - Parameters: + /// - encodable: The object to encode. + /// - status: The status code to use. Defaults to `ok`. + /// - encoder: The encoder to use. Defaults to a new `JSONEncoder`. + /// - onError: A closure to handle errors, and transform them into a `APIGatewayV2Response`. Defaults + /// to converting the error into a 500 (Internal Server Error) response with the error message as the body. + static func encoding( + _ encodable: T, + status: HTTPResponse.Status = .ok, + using encoder: JSONEncoder = JSONEncoder(), + onError: ((Error) -> Self)? = nil + ) -> Self where T: Encodable { + do { + let encodedResponse = try encoder.encode(encodable) + return APIGatewayV2Response( + statusCode: status, + body: String(data: encodedResponse, encoding: .utf8) + ) + } catch { + return (onError ?? defaultErrorHandler)(error) + } + } +} + +private func defaultErrorHandler(_ error: Error) -> APIGatewayV2Response { + APIGatewayV2Response( + statusCode: .internalServerError, + body: "Internal Server Error: \(String(describing: error))" + ) +} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Decode.swift b/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Decode.swift new file mode 100644 index 0000000..c24fe55 --- /dev/null +++ b/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Decode.swift @@ -0,0 +1,48 @@ +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +import HTTPTypes + +public extension FunctionURLRequest { + /// Decodes the body of the request into a `Data` object. + /// + /// - Returns: The decoded body as `Data` or `nil` if the body is empty. + func decodeBody() throws -> Data? { + guard let body else { return nil } + + if isBase64Encoded, + let base64Decoded = Data(base64Encoded: body) { + return base64Decoded + } + + return body.data(using: .utf8) + } + + /// Decodes the body of the request into a decodable object. When the + /// body is empty, an error is thrown. + /// + /// - Parameters: + /// - type: The type to decode the body into. + /// - decoder: The decoder to use. Defaults to `JSONDecoder()`. + /// + /// - Returns: The decoded body as `T`. + /// - Throws: An error if the body cannot be decoded. + func decodeBody( + _ type: T.Type, + using decoder: JSONDecoder = JSONDecoder() + ) throws -> T where T: Decodable { + let bodyData = body?.data(using: .utf8) ?? Data() + + var requestData = bodyData + + if isBase64Encoded, + let base64Decoded = Data(base64Encoded: requestData) { + requestData = base64Decoded + } + + return try decoder.decode(T.self, from: requestData) + } +} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Encode.swift b/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Encode.swift new file mode 100644 index 0000000..c53b16a --- /dev/null +++ b/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Encode.swift @@ -0,0 +1,41 @@ +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +import HTTPTypes + +public extension FunctionURLResponse { + /// Encodes a given encodable object into a `FunctionURLResponse` object. + /// + /// - Parameters: + /// - encodable: The object to encode. + /// - status: The status code to use. Defaults to `ok`. + /// - encoder: The encoder to use. Defaults to a new `JSONEncoder`. + /// - onError: A closure to handle errors, and transform them into a `FunctionURLResponse`. Defaults + /// to converting the error into a 500 (Internal Server Error) response with the error message as the body. + static func encoding( + _ encodable: T, + status: HTTPResponse.Status = .ok, + using encoder: JSONEncoder = JSONEncoder(), + onError: ((Error) -> Self)? = nil + ) -> Self where T: Encodable { + do { + let encodedResponse = try encoder.encode(encodable) + return FunctionURLResponse( + statusCode: status, + body: String(data: encodedResponse, encoding: .utf8) + ) + } catch { + return (onError ?? defaultErrorHandler)(error) + } + } +} + +private func defaultErrorHandler(_ error: Error) -> FunctionURLResponse { + FunctionURLResponse( + statusCode: .internalServerError, + body: "Internal Server Error: \(String(describing: error))" + ) +} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/SQS+Decode.swift b/Sources/AWSLambdaEvents/Codable Helpers/SQS+Decode.swift new file mode 100644 index 0000000..ce86a3f --- /dev/null +++ b/Sources/AWSLambdaEvents/Codable Helpers/SQS+Decode.swift @@ -0,0 +1,41 @@ +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +public extension SQSEvent { + /// Decodes the records included in the event into an array of decodable objects. + /// + /// - Parameters: + /// - type: The type to decode the body into. + /// - decoder: The decoder to use. Defaults to a new `JSONDecoder`. + /// + /// - Returns: The decoded records as `[T]`. + /// - Throws: An error if any of the records cannot be decoded. + func decodeBody( + _ type: T.Type, + using decoder: JSONDecoder = JSONDecoder() + ) throws -> [T] where T: Decodable { + try records.map { + try $0.decodeBody(type, using: decoder) + } + } +} + +public extension SQSEvent.Message { + /// Decodes the body of the message into a decodable object. + /// + /// - Parameters: + /// - type: The type to decode the body into. + /// - decoder: The decoder to use. Defaults to a new `JSONDecoder`. + /// + /// - Returns: The decoded body as `T`. + /// - Throws: An error if the body cannot be decoded. + func decodeBody( + _ type: T.Type, + using decoder: JSONDecoder = JSONDecoder() + ) throws -> T where T: Decodable { + try decoder.decode(T.self, from: body.data(using: .utf8) ?? Data()) + } +} \ No newline at end of file From 38254f259b0ab60239580945bfbd377db4cd0cc0 Mon Sep 17 00:00:00 2001 From: Natan Rolnik Date: Wed, 6 Aug 2025 00:11:07 +0300 Subject: [PATCH 2/4] Add tests --- .../APIGateway+EncodableTests.swift | 106 ++++++++ .../APIGateway+V2Tests.swift | 150 +++++++++++ .../FunctionURLTests.swift | 235 ++++++++++++++++++ Tests/AWSLambdaEventsTests/SQSTests.swift | 148 +++++++++++ 4 files changed, 639 insertions(+) diff --git a/Tests/AWSLambdaEventsTests/APIGateway+EncodableTests.swift b/Tests/AWSLambdaEventsTests/APIGateway+EncodableTests.swift index 0f014e5..70814f5 100644 --- a/Tests/AWSLambdaEventsTests/APIGateway+EncodableTests.swift +++ b/Tests/AWSLambdaEventsTests/APIGateway+EncodableTests.swift @@ -69,4 +69,110 @@ struct APIGatewayEncodableResponseTests { #expect(encodedBody == businessResponse) } } + + // MARK: APIGatewayV2 Encoding Helper Tests + + @Test + func testAPIGatewayV2ResponseEncodingHelper() throws { + // given + let businessResponse = BusinessResponse(message: "Hello World", code: 200) + + // when + let response = APIGatewayV2Response.encoding(businessResponse) + + // then + #expect(response.statusCode == .ok) + #expect(response.body != nil) + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) + #expect(decodedResponse == businessResponse) + } + + @Test + func testAPIGatewayV2ResponseEncodingHelperWithCustomStatus() throws { + // given + let businessResponse = BusinessResponse(message: "Created", code: 201) + + // when + let response = APIGatewayV2Response.encoding(businessResponse, status: .created) + + // then + #expect(response.statusCode == .created) + #expect(response.body != nil) + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) + #expect(decodedResponse == businessResponse) + } + + @Test + func testAPIGatewayV2ResponseEncodingHelperWithCustomEncoder() throws { + // given + let businessResponse = BusinessResponse(message: "Hello World", code: 200) + let customEncoder = JSONEncoder() + customEncoder.outputFormatting = .prettyPrinted + + // when + let response = APIGatewayV2Response.encoding(businessResponse, using: customEncoder) + + // then + #expect(response.statusCode == .ok) + #expect(response.body != nil) + #expect(response.body?.contains("\n") == true) // Pretty printed JSON should contain newlines + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) + #expect(decodedResponse == businessResponse) + } + + @Test + func testAPIGatewayV2ResponseEncodingHelperWithError() throws { + // given + struct InvalidEncodable: Encodable { + func encode(to encoder: Encoder) throws { + throw TestError.encodingFailed + } + } + + enum TestError: Error { + case encodingFailed + } + + let invalidObject = InvalidEncodable() + + // when + let response = APIGatewayV2Response.encoding(invalidObject) + + // then + #expect(response.statusCode == .internalServerError) + #expect(response.body?.contains("Internal Server Error") == true) + } + + @Test + func testAPIGatewayV2ResponseEncodingHelperWithCustomErrorHandler() throws { + // given + struct InvalidEncodable: Encodable { + func encode(to encoder: Encoder) throws { + throw TestError.encodingFailed + } + } + + enum TestError: Error { + case encodingFailed + } + + let invalidObject = InvalidEncodable() + let customErrorHandler: (Error) -> APIGatewayV2Response = { _ in + APIGatewayV2Response(statusCode: .badRequest, body: "Custom error message") + } + + // when + let response = APIGatewayV2Response.encoding(invalidObject, onError: customErrorHandler) + + // then + #expect(response.statusCode == .badRequest) + #expect(response.body == "Custom error message") + } + } diff --git a/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift b/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift index 9d0a56a..32d623f 100644 --- a/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift +++ b/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift @@ -224,4 +224,154 @@ struct APIGatewayV2Tests { _ = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) } } + + // MARK: Codable Helpers Tests + + @Test func decodeBodyWithNilBody() throws { + let data = APIGatewayV2Tests.exampleGetEventBodyNilHeaders.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + let decodedBody = try request.decodeBody() + #expect(decodedBody == nil) + } + + @Test func decodeBodyWithPlainTextBody() throws { + let requestWithBody = """ + { + "routeKey":"POST /hello", + "version":"2.0", + "rawPath":"/hello", + "rawQueryString":"", + "requestContext":{ + "timeEpoch":1587750461466, + "domainPrefix":"hello", + "accountId":"0123456789", + "stage":"$default", + "domainName":"hello.test.com", + "apiId":"pb5dg6g3rg", + "requestId":"LgLpnibOFiAEPCA=", + "http":{ + "path":"/hello", + "userAgent":"test", + "method":"POST", + "protocol":"HTTP/1.1", + "sourceIp":"127.0.0.1" + }, + "time":"24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded":false, + "body":"Hello World!" + } + """ + + let data = requestWithBody.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + let decodedBody = try request.decodeBody() + let expectedBody = "Hello World!".data(using: .utf8) + #expect(decodedBody == expectedBody) + } + + @Test func decodeBodyWithBase64EncodedBody() throws { + let requestWithBase64Body = """ + { + "routeKey":"POST /hello", + "version":"2.0", + "rawPath":"/hello", + "rawQueryString":"", + "requestContext":{ + "timeEpoch":1587750461466, + "domainPrefix":"hello", + "accountId":"0123456789", + "stage":"$default", + "domainName":"hello.test.com", + "apiId":"pb5dg6g3rg", + "requestId":"LgLpnibOFiAEPCA=", + "http":{ + "path":"/hello", + "userAgent":"test", + "method":"POST", + "protocol":"HTTP/1.1", + "sourceIp":"127.0.0.1" + }, + "time":"24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded":true, + "body":"SGVsbG8gV29ybGQh" + } + """ + + let data = requestWithBase64Body.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + let decodedBody = try request.decodeBody() + let expectedBody = "Hello World!".data(using: .utf8) + #expect(decodedBody == expectedBody) + } + + @Test func decodeBodyAsDecodableType() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + // Use the fullExamplePayload which already has a simple JSON body + let data = APIGatewayV2Tests.fullExamplePayload.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + // Test that we can decode the body as String + // The fullExamplePayload has body: "Hello from Lambda" which is not valid JSON, so this should fail + #expect(throws: DecodingError.self) { + _ = try request.decodeBody(TestPayload.self) + } + } + + @Test func decodeBodyAsDecodableTypeWithCustomDecoder() throws { + struct TestPayload: Codable, Equatable { + let messageText: String + let count: Int + + enum CodingKeys: String, CodingKey { + case messageText = "message_text" + case count + } + } + + let testPayload = TestPayload(messageText: "test", count: 42) + + let requestWithBase64JSONBody = """ + { + "routeKey":"POST /hello", + "version":"2.0", + "rawPath":"/hello", + "rawQueryString":"", + "requestContext":{ + "timeEpoch":1587750461466, + "domainPrefix":"hello", + "accountId":"0123456789", + "stage":"$default", + "domainName":"hello.test.com", + "apiId":"pb5dg6g3rg", + "requestId":"LgLpnibOFiAEPCA=", + "http":{ + "path":"/hello", + "userAgent":"test", + "method":"POST", + "protocol":"HTTP/1.1", + "sourceIp":"127.0.0.1" + }, + "time":"24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded":true, + "body":"eyJtZXNzYWdlX3RleHQiOiJ0ZXN0IiwiY291bnQiOjQyfQ==", + "headers":{} + } + """ + + let data = requestWithBase64JSONBody.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + let decodedPayload = try request.decodeBody(TestPayload.self) + #expect(decodedPayload == testPayload) + } } diff --git a/Tests/AWSLambdaEventsTests/FunctionURLTests.swift b/Tests/AWSLambdaEventsTests/FunctionURLTests.swift index 4a022f6..cdd8116 100644 --- a/Tests/AWSLambdaEventsTests/FunctionURLTests.swift +++ b/Tests/AWSLambdaEventsTests/FunctionURLTests.swift @@ -150,4 +150,239 @@ struct FunctionURLTests { #expect(req.cookies == ["test"]) #expect(req.body == nil) } + + // MARK: Codable Helpers Tests + + @Test func decodeBodyWithNilBody() throws { + let data = Self.realWorldExample.data(using: .utf8)! + let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) + + let decodedBody = try request.decodeBody() + #expect(decodedBody == nil) + } + + @Test func decodeBodyWithPlainTextBody() throws { + let data = Self.documentationExample.data(using: .utf8)! + let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) + + let decodedBody = try request.decodeBody() + let expectedBody = "Hello from client!".data(using: .utf8) + #expect(decodedBody == expectedBody) + } + + @Test func decodeBodyWithBase64EncodedBody() throws { + let requestWithBase64Body = """ + { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/test", + "rawQueryString": "", + "headers": {}, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "domainName": ".lambda-url.us-west-2.on.aws", + "domainPrefix": "", + "http": { + "method": "POST", + "path": "/test", + "protocol": "HTTP/1.1", + "sourceIp": "123.123.123.123", + "userAgent": "test" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": "SGVsbG8gZnJvbSBjbGllbnQh", + "isBase64Encoded": true + } + """ + + let data = requestWithBase64Body.data(using: .utf8)! + let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) + + let decodedBody = try request.decodeBody() + let expectedBody = "Hello from client!".data(using: .utf8) + #expect(decodedBody == expectedBody) + } + + @Test func decodeBodyAsDecodableType() throws { + // Use the documentationExample which already has a simple string body + let data = Self.documentationExample.data(using: .utf8)! + let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) + + // Test that we can decode the body as String + // The documentationExample has body: "Hello from client!" which is not valid JSON, so this should fail + #expect(throws: DecodingError.self) { + _ = try request.decodeBody(String.self) + } + } + + @Test func decodeBodyAsDecodableTypeWithBase64() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "test", count: 42) + + let requestWithBase64JSONBody = """ + { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/test", + "rawQueryString": "", + "headers": {}, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "domainName": ".lambda-url.us-west-2.on.aws", + "domainPrefix": "", + "http": { + "method": "POST", + "path": "/test", + "protocol": "HTTP/1.1", + "sourceIp": "123.123.123.123", + "userAgent": "test" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": "eyJtZXNzYWdlIjoidGVzdCIsImNvdW50Ijo0Mn0=", + "isBase64Encoded": true + } + """ + + let data = requestWithBase64JSONBody.data(using: .utf8)! + let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) + + let decodedPayload = try request.decodeBody(TestPayload.self) + #expect(decodedPayload == testPayload) + } + + // MARK: FunctionURL Encoding Helper Tests + + @Test + func testFunctionURLResponseEncodingHelper() throws { + struct BusinessResponse: Codable, Equatable { + let message: String + let code: Int + } + + // given + let businessResponse = BusinessResponse(message: "Hello World", code: 200) + + // when + let response = FunctionURLResponse.encoding(businessResponse) + + // then + #expect(response.statusCode == .ok) + #expect(response.body != nil) + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) + #expect(decodedResponse == businessResponse) + } + + @Test + func testFunctionURLResponseEncodingHelperWithCustomStatus() throws { + struct BusinessResponse: Codable, Equatable { + let message: String + let code: Int + } + + // given + let businessResponse = BusinessResponse(message: "Created", code: 201) + + // when + let response = FunctionURLResponse.encoding(businessResponse, status: .created) + + // then + #expect(response.statusCode == .created) + #expect(response.body != nil) + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) + #expect(decodedResponse == businessResponse) + } + + @Test + func testFunctionURLResponseEncodingHelperWithCustomEncoder() throws { + struct BusinessResponse: Codable, Equatable { + let message: String + let code: Int + } + + // given + let businessResponse = BusinessResponse(message: "Hello World", code: 200) + let customEncoder = JSONEncoder() + customEncoder.outputFormatting = .prettyPrinted + + // when + let response = FunctionURLResponse.encoding(businessResponse, using: customEncoder) + + // then + #expect(response.statusCode == .ok) + #expect(response.body != nil) + #expect(response.body?.contains("\n") == true) // Pretty printed JSON should contain newlines + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) + #expect(decodedResponse == businessResponse) + } + + @Test + func testFunctionURLResponseEncodingHelperWithError() throws { + // given + struct InvalidEncodable: Encodable { + func encode(to encoder: Encoder) throws { + throw TestError.encodingFailed + } + } + + enum TestError: Error { + case encodingFailed + } + + let invalidObject = InvalidEncodable() + + // when + let response = FunctionURLResponse.encoding(invalidObject) + + // then + #expect(response.statusCode == .internalServerError) + #expect(response.body?.contains("Internal Server Error") == true) + } + + @Test + func testFunctionURLResponseEncodingHelperWithCustomErrorHandler() throws { + // given + struct InvalidEncodable: Encodable { + func encode(to encoder: Encoder) throws { + throw TestError.encodingFailed + } + } + + enum TestError: Error { + case encodingFailed + } + + let invalidObject = InvalidEncodable() + let customErrorHandler: (Error) -> FunctionURLResponse = { _ in + FunctionURLResponse(statusCode: .badRequest, body: "Custom error message") + } + + // when + let response = FunctionURLResponse.encoding(invalidObject, onError: customErrorHandler) + + // then + #expect(response.statusCode == .badRequest) + #expect(response.body == "Custom error message") + } } diff --git a/Tests/AWSLambdaEventsTests/SQSTests.swift b/Tests/AWSLambdaEventsTests/SQSTests.swift index 5cc770e..f71651f 100644 --- a/Tests/AWSLambdaEventsTests/SQSTests.swift +++ b/Tests/AWSLambdaEventsTests/SQSTests.swift @@ -88,4 +88,152 @@ struct SQSTests { #expect(message.eventSourceArn == "arn:aws:sqs:us-east-1:123456789012:MyQueue") #expect(message.awsRegion == .us_east_1) } + + // MARK: Codable Helpers Tests + + @Test func decodeBodyForSingleMessage() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "test", count: 42) + + let eventBodyWithJSON = """ + { + "Records": [ + { + "messageId": "test-message-id", + "receiptHandle": "test-receipt-handle", + "body": "{\\"message\\":\\"test\\",\\"count\\":42}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "test-md5", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", + "awsRegion": "us-east-1" + } + ] + } + """ + + let data = eventBodyWithJSON.data(using: .utf8)! + let event = try JSONDecoder().decode(SQSEvent.self, from: data) + + guard let message = event.records.first else { + Issue.record("Expected to have one message in the event") + return + } + + let decodedPayload = try message.decodeBody(TestPayload.self) + #expect(decodedPayload == testPayload) + } + + @Test func decodeBodyForSQSEvent() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload1 = TestPayload(message: "test1", count: 42) + let testPayload2 = TestPayload(message: "test2", count: 84) + + let eventBodyWithMultipleRecords = """ + { + "Records": [ + { + "messageId": "test-message-id-1", + "receiptHandle": "test-receipt-handle-1", + "body": "{\\"message\\":\\"test1\\",\\"count\\":42}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "test-md5-1", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", + "awsRegion": "us-east-1" + }, + { + "messageId": "test-message-id-2", + "receiptHandle": "test-receipt-handle-2", + "body": "{\\"message\\":\\"test2\\",\\"count\\":84}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "test-md5-2", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", + "awsRegion": "us-east-1" + } + ] + } + """ + + let data = eventBodyWithMultipleRecords.data(using: .utf8)! + let event = try JSONDecoder().decode(SQSEvent.self, from: data) + + let decodedPayloads = try event.decodeBody(TestPayload.self) + #expect(decodedPayloads.count == 2) + #expect(decodedPayloads[0] == testPayload1) + #expect(decodedPayloads[1] == testPayload2) + } + + @Test func decodeBodyWithCustomDecoder() throws { + struct TestPayload: Codable, Equatable { + let messageText: String + let count: Int + + enum CodingKeys: String, CodingKey { + case messageText = "message_text" + case count + } + } + + let testPayload = TestPayload(messageText: "test", count: 42) + + // We need to create a decoder that can handle the explicit coding keys + + let eventBodyWithSnakeCase = """ + { + "Records": [ + { + "messageId": "test-message-id", + "receiptHandle": "test-receipt-handle", + "body": "{\\"message_text\\":\\"test\\",\\"count\\":42}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "test-md5", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", + "awsRegion": "us-east-1" + } + ] + } + """ + + let data = eventBodyWithSnakeCase.data(using: .utf8)! + let event = try JSONDecoder().decode(SQSEvent.self, from: data) + + let decodedPayloads = try event.decodeBody(TestPayload.self) + #expect(decodedPayloads.count == 1) + #expect(decodedPayloads[0] == testPayload) + } } From e9b5e7989bb230072371b413aaeb8c885fa3537d Mon Sep 17 00:00:00 2001 From: Natan Rolnik Date: Wed, 6 Aug 2025 12:22:08 +0300 Subject: [PATCH 3/4] Address PR comments (and format) --- .../Codable Helpers/APIGatewayV2+Encode.swift | 41 -- ...V2+Decode.swift => DecodableRequest.swift} | 42 +- .../Codable Helpers/EncodableResponse.swift | 107 ++++ .../Codable Helpers/FunctionURL+Decode.swift | 48 -- .../Codable Helpers/FunctionURL+Encode.swift | 41 -- .../Codable Helpers/SQS+Decode.swift | 24 +- .../APIGateway+EncodableTests.swift | 106 ---- .../APIGateway+V2Tests.swift | 176 +++--- .../CodableHelpersTests.swift | 504 ++++++++++++++++++ .../FunctionURLTests.swift | 246 +++------ Tests/AWSLambdaEventsTests/SQSTests.swift | 176 +++--- 11 files changed, 905 insertions(+), 606 deletions(-) delete mode 100644 Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Encode.swift rename Sources/AWSLambdaEvents/Codable Helpers/{APIGatewayV2+Decode.swift => DecodableRequest.swift} (51%) create mode 100644 Sources/AWSLambdaEvents/Codable Helpers/EncodableResponse.swift delete mode 100644 Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Decode.swift delete mode 100644 Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Encode.swift create mode 100644 Tests/AWSLambdaEventsTests/CodableHelpersTests.swift diff --git a/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Encode.swift b/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Encode.swift deleted file mode 100644 index 19c429b..0000000 --- a/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Encode.swift +++ /dev/null @@ -1,41 +0,0 @@ -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -import HTTPTypes - -public extension APIGatewayV2Response { - /// Encodes a given encodable object into a `APIGatewayV2Response` object. - /// - /// - Parameters: - /// - encodable: The object to encode. - /// - status: The status code to use. Defaults to `ok`. - /// - encoder: The encoder to use. Defaults to a new `JSONEncoder`. - /// - onError: A closure to handle errors, and transform them into a `APIGatewayV2Response`. Defaults - /// to converting the error into a 500 (Internal Server Error) response with the error message as the body. - static func encoding( - _ encodable: T, - status: HTTPResponse.Status = .ok, - using encoder: JSONEncoder = JSONEncoder(), - onError: ((Error) -> Self)? = nil - ) -> Self where T: Encodable { - do { - let encodedResponse = try encoder.encode(encodable) - return APIGatewayV2Response( - statusCode: status, - body: String(data: encodedResponse, encoding: .utf8) - ) - } catch { - return (onError ?? defaultErrorHandler)(error) - } - } -} - -private func defaultErrorHandler(_ error: Error) -> APIGatewayV2Response { - APIGatewayV2Response( - statusCode: .internalServerError, - body: "Internal Server Error: \(String(describing: error))" - ) -} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Decode.swift b/Sources/AWSLambdaEvents/Codable Helpers/DecodableRequest.swift similarity index 51% rename from Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Decode.swift rename to Sources/AWSLambdaEvents/Codable Helpers/DecodableRequest.swift index f72178f..592ee62 100644 --- a/Sources/AWSLambdaEvents/Codable Helpers/APIGatewayV2+Decode.swift +++ b/Sources/AWSLambdaEvents/Codable Helpers/DecodableRequest.swift @@ -1,20 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import HTTPTypes +public protocol DecodableRequest { + var body: String? { get } + var isBase64Encoded: Bool { get } + + func decodeBody() throws -> Data? + func decodeBody( + _ type: T.Type, + using decoder: JSONDecoder + ) throws -> T where T: Decodable +} -public extension APIGatewayV2Request { +extension DecodableRequest { /// Decodes the body of the request into a `Data` object. /// /// - Returns: The decoded body as `Data` or `nil` if the body is empty. - func decodeBody() throws -> Data? { + public func decodeBody() throws -> Data? { guard let body else { return nil } if isBase64Encoded, - let base64Decoded = Data(base64Encoded: body) { + let base64Decoded = Data(base64Encoded: body) + { return base64Decoded } @@ -30,7 +56,7 @@ public extension APIGatewayV2Request { /// /// - Returns: The decoded body as `T`. /// - Throws: An error if the body cannot be decoded. - func decodeBody( + public func decodeBody( _ type: T.Type, using decoder: JSONDecoder = JSONDecoder() ) throws -> T where T: Decodable { @@ -39,10 +65,14 @@ public extension APIGatewayV2Request { var requestData = bodyData if isBase64Encoded, - let base64Decoded = Data(base64Encoded: requestData) { + let base64Decoded = Data(base64Encoded: requestData) + { requestData = base64Decoded } return try decoder.decode(T.self, from: requestData) } } + +extension APIGatewayV2Request: DecodableRequest {} +extension FunctionURLRequest: DecodableRequest {} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/EncodableResponse.swift b/Sources/AWSLambdaEvents/Codable Helpers/EncodableResponse.swift new file mode 100644 index 0000000..3bfb6fd --- /dev/null +++ b/Sources/AWSLambdaEvents/Codable Helpers/EncodableResponse.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +public protocol EncodableResponse { + static func encoding( + _ encodable: T, + status: HTTPResponse.Status, + using encoder: JSONEncoder, + headers: HTTPHeaders?, + cookies: [String]?, + onError: ((Error) -> Self) + ) -> Self where T: Encodable + + init( + statusCode: HTTPResponse.Status, + headers: HTTPHeaders?, + body: String?, + cookies: [String]?, + isBase64Encoded: Bool? + ) +} + +extension EncodableResponse { + /// Encodes a given encodable object into a response object. + /// + /// - Parameters: + /// - encodable: The object to encode. + /// - status: The status code to use. Defaults to `ok`. + /// - encoder: The encoder to use. Defaults to a new `JSONEncoder`. + /// - onError: A closure to handle errors, and transform them into a `APIGatewayV2Response`. + /// Defaults to converting the error into a 500 (Internal Server Error) response with the error message as + /// the body. + /// + /// - Returns: a response object whose body is the encoded `encodable` type and with the + /// other response parameters + public static func encoding( + _ encodable: T, + status: HTTPResponse.Status = .ok, + using encoder: JSONEncoder = JSONEncoder(), + headers: HTTPHeaders? = nil, + cookies: [String]? = nil, + onError: ((Error) -> Self) = Self.defaultErrorHandler + ) -> Self where T: Encodable { + do { + let encodedResponse = try encoder.encode(encodable) + return Self( + statusCode: status, + headers: headers, + body: String(data: encodedResponse, encoding: .utf8), + cookies: cookies, + isBase64Encoded: nil + ) + } catch { + return onError(error) + } + } + + public static var defaultErrorHandler: ((Error) -> Self) { + { error in + Self( + statusCode: .internalServerError, + headers: nil, + body: "Internal Server Error: \(String(describing: error))", + cookies: nil, + isBase64Encoded: nil + ) + } + } +} + +extension APIGatewayV2Response: EncodableResponse { + public init( + statusCode: HTTPResponse.Status, + headers: HTTPHeaders?, + body: String?, + cookies: [String]?, + isBase64Encoded: Bool? + ) { + self.init( + statusCode: statusCode, + headers: headers, + body: body, + isBase64Encoded: isBase64Encoded, + cookies: cookies + ) + } +} +extension FunctionURLResponse: EncodableResponse {} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Decode.swift b/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Decode.swift deleted file mode 100644 index c24fe55..0000000 --- a/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Decode.swift +++ /dev/null @@ -1,48 +0,0 @@ -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -import HTTPTypes - -public extension FunctionURLRequest { - /// Decodes the body of the request into a `Data` object. - /// - /// - Returns: The decoded body as `Data` or `nil` if the body is empty. - func decodeBody() throws -> Data? { - guard let body else { return nil } - - if isBase64Encoded, - let base64Decoded = Data(base64Encoded: body) { - return base64Decoded - } - - return body.data(using: .utf8) - } - - /// Decodes the body of the request into a decodable object. When the - /// body is empty, an error is thrown. - /// - /// - Parameters: - /// - type: The type to decode the body into. - /// - decoder: The decoder to use. Defaults to `JSONDecoder()`. - /// - /// - Returns: The decoded body as `T`. - /// - Throws: An error if the body cannot be decoded. - func decodeBody( - _ type: T.Type, - using decoder: JSONDecoder = JSONDecoder() - ) throws -> T where T: Decodable { - let bodyData = body?.data(using: .utf8) ?? Data() - - var requestData = bodyData - - if isBase64Encoded, - let base64Decoded = Data(base64Encoded: requestData) { - requestData = base64Decoded - } - - return try decoder.decode(T.self, from: requestData) - } -} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Encode.swift b/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Encode.swift deleted file mode 100644 index c53b16a..0000000 --- a/Sources/AWSLambdaEvents/Codable Helpers/FunctionURL+Encode.swift +++ /dev/null @@ -1,41 +0,0 @@ -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif - -import HTTPTypes - -public extension FunctionURLResponse { - /// Encodes a given encodable object into a `FunctionURLResponse` object. - /// - /// - Parameters: - /// - encodable: The object to encode. - /// - status: The status code to use. Defaults to `ok`. - /// - encoder: The encoder to use. Defaults to a new `JSONEncoder`. - /// - onError: A closure to handle errors, and transform them into a `FunctionURLResponse`. Defaults - /// to converting the error into a 500 (Internal Server Error) response with the error message as the body. - static func encoding( - _ encodable: T, - status: HTTPResponse.Status = .ok, - using encoder: JSONEncoder = JSONEncoder(), - onError: ((Error) -> Self)? = nil - ) -> Self where T: Encodable { - do { - let encodedResponse = try encoder.encode(encodable) - return FunctionURLResponse( - statusCode: status, - body: String(data: encodedResponse, encoding: .utf8) - ) - } catch { - return (onError ?? defaultErrorHandler)(error) - } - } -} - -private func defaultErrorHandler(_ error: Error) -> FunctionURLResponse { - FunctionURLResponse( - statusCode: .internalServerError, - body: "Internal Server Error: \(String(describing: error))" - ) -} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/SQS+Decode.swift b/Sources/AWSLambdaEvents/Codable Helpers/SQS+Decode.swift index ce86a3f..55bb2c5 100644 --- a/Sources/AWSLambdaEvents/Codable Helpers/SQS+Decode.swift +++ b/Sources/AWSLambdaEvents/Codable Helpers/SQS+Decode.swift @@ -1,10 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -public extension SQSEvent { +extension SQSEvent { /// Decodes the records included in the event into an array of decodable objects. /// /// - Parameters: @@ -13,7 +27,7 @@ public extension SQSEvent { /// /// - Returns: The decoded records as `[T]`. /// - Throws: An error if any of the records cannot be decoded. - func decodeBody( + public func decodeBody( _ type: T.Type, using decoder: JSONDecoder = JSONDecoder() ) throws -> [T] where T: Decodable { @@ -23,7 +37,7 @@ public extension SQSEvent { } } -public extension SQSEvent.Message { +extension SQSEvent.Message { /// Decodes the body of the message into a decodable object. /// /// - Parameters: @@ -32,10 +46,10 @@ public extension SQSEvent.Message { /// /// - Returns: The decoded body as `T`. /// - Throws: An error if the body cannot be decoded. - func decodeBody( + public func decodeBody( _ type: T.Type, using decoder: JSONDecoder = JSONDecoder() ) throws -> T where T: Decodable { try decoder.decode(T.self, from: body.data(using: .utf8) ?? Data()) } -} \ No newline at end of file +} diff --git a/Tests/AWSLambdaEventsTests/APIGateway+EncodableTests.swift b/Tests/AWSLambdaEventsTests/APIGateway+EncodableTests.swift index 70814f5..0f014e5 100644 --- a/Tests/AWSLambdaEventsTests/APIGateway+EncodableTests.swift +++ b/Tests/AWSLambdaEventsTests/APIGateway+EncodableTests.swift @@ -69,110 +69,4 @@ struct APIGatewayEncodableResponseTests { #expect(encodedBody == businessResponse) } } - - // MARK: APIGatewayV2 Encoding Helper Tests - - @Test - func testAPIGatewayV2ResponseEncodingHelper() throws { - // given - let businessResponse = BusinessResponse(message: "Hello World", code: 200) - - // when - let response = APIGatewayV2Response.encoding(businessResponse) - - // then - #expect(response.statusCode == .ok) - #expect(response.body != nil) - - let bodyData = try #require(response.body?.data(using: .utf8)) - let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) - #expect(decodedResponse == businessResponse) - } - - @Test - func testAPIGatewayV2ResponseEncodingHelperWithCustomStatus() throws { - // given - let businessResponse = BusinessResponse(message: "Created", code: 201) - - // when - let response = APIGatewayV2Response.encoding(businessResponse, status: .created) - - // then - #expect(response.statusCode == .created) - #expect(response.body != nil) - - let bodyData = try #require(response.body?.data(using: .utf8)) - let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) - #expect(decodedResponse == businessResponse) - } - - @Test - func testAPIGatewayV2ResponseEncodingHelperWithCustomEncoder() throws { - // given - let businessResponse = BusinessResponse(message: "Hello World", code: 200) - let customEncoder = JSONEncoder() - customEncoder.outputFormatting = .prettyPrinted - - // when - let response = APIGatewayV2Response.encoding(businessResponse, using: customEncoder) - - // then - #expect(response.statusCode == .ok) - #expect(response.body != nil) - #expect(response.body?.contains("\n") == true) // Pretty printed JSON should contain newlines - - let bodyData = try #require(response.body?.data(using: .utf8)) - let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) - #expect(decodedResponse == businessResponse) - } - - @Test - func testAPIGatewayV2ResponseEncodingHelperWithError() throws { - // given - struct InvalidEncodable: Encodable { - func encode(to encoder: Encoder) throws { - throw TestError.encodingFailed - } - } - - enum TestError: Error { - case encodingFailed - } - - let invalidObject = InvalidEncodable() - - // when - let response = APIGatewayV2Response.encoding(invalidObject) - - // then - #expect(response.statusCode == .internalServerError) - #expect(response.body?.contains("Internal Server Error") == true) - } - - @Test - func testAPIGatewayV2ResponseEncodingHelperWithCustomErrorHandler() throws { - // given - struct InvalidEncodable: Encodable { - func encode(to encoder: Encoder) throws { - throw TestError.encodingFailed - } - } - - enum TestError: Error { - case encodingFailed - } - - let invalidObject = InvalidEncodable() - let customErrorHandler: (Error) -> APIGatewayV2Response = { _ in - APIGatewayV2Response(statusCode: .badRequest, body: "Custom error message") - } - - // when - let response = APIGatewayV2Response.encoding(invalidObject, onError: customErrorHandler) - - // then - #expect(response.statusCode == .badRequest) - #expect(response.body == "Custom error message") - } - } diff --git a/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift b/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift index 32d623f..1fb2037 100644 --- a/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift +++ b/Tests/AWSLambdaEventsTests/APIGateway+V2Tests.swift @@ -230,43 +230,43 @@ struct APIGatewayV2Tests { @Test func decodeBodyWithNilBody() throws { let data = APIGatewayV2Tests.exampleGetEventBodyNilHeaders.data(using: .utf8)! let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) - + let decodedBody = try request.decodeBody() #expect(decodedBody == nil) } @Test func decodeBodyWithPlainTextBody() throws { let requestWithBody = """ - { - "routeKey":"POST /hello", - "version":"2.0", - "rawPath":"/hello", - "rawQueryString":"", - "requestContext":{ - "timeEpoch":1587750461466, - "domainPrefix":"hello", - "accountId":"0123456789", - "stage":"$default", - "domainName":"hello.test.com", - "apiId":"pb5dg6g3rg", - "requestId":"LgLpnibOFiAEPCA=", - "http":{ - "path":"/hello", - "userAgent":"test", - "method":"POST", - "protocol":"HTTP/1.1", - "sourceIp":"127.0.0.1" + { + "routeKey":"POST /hello", + "version":"2.0", + "rawPath":"/hello", + "rawQueryString":"", + "requestContext":{ + "timeEpoch":1587750461466, + "domainPrefix":"hello", + "accountId":"0123456789", + "stage":"$default", + "domainName":"hello.test.com", + "apiId":"pb5dg6g3rg", + "requestId":"LgLpnibOFiAEPCA=", + "http":{ + "path":"/hello", + "userAgent":"test", + "method":"POST", + "protocol":"HTTP/1.1", + "sourceIp":"127.0.0.1" + }, + "time":"24/Apr/2020:17:47:41 +0000" }, - "time":"24/Apr/2020:17:47:41 +0000" - }, - "isBase64Encoded":false, - "body":"Hello World!" - } - """ - + "isBase64Encoded":false, + "body":"Hello World!" + } + """ + let data = requestWithBody.data(using: .utf8)! let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) - + let decodedBody = try request.decodeBody() let expectedBody = "Hello World!".data(using: .utf8) #expect(decodedBody == expectedBody) @@ -274,36 +274,36 @@ struct APIGatewayV2Tests { @Test func decodeBodyWithBase64EncodedBody() throws { let requestWithBase64Body = """ - { - "routeKey":"POST /hello", - "version":"2.0", - "rawPath":"/hello", - "rawQueryString":"", - "requestContext":{ - "timeEpoch":1587750461466, - "domainPrefix":"hello", - "accountId":"0123456789", - "stage":"$default", - "domainName":"hello.test.com", - "apiId":"pb5dg6g3rg", - "requestId":"LgLpnibOFiAEPCA=", - "http":{ - "path":"/hello", - "userAgent":"test", - "method":"POST", - "protocol":"HTTP/1.1", - "sourceIp":"127.0.0.1" + { + "routeKey":"POST /hello", + "version":"2.0", + "rawPath":"/hello", + "rawQueryString":"", + "requestContext":{ + "timeEpoch":1587750461466, + "domainPrefix":"hello", + "accountId":"0123456789", + "stage":"$default", + "domainName":"hello.test.com", + "apiId":"pb5dg6g3rg", + "requestId":"LgLpnibOFiAEPCA=", + "http":{ + "path":"/hello", + "userAgent":"test", + "method":"POST", + "protocol":"HTTP/1.1", + "sourceIp":"127.0.0.1" + }, + "time":"24/Apr/2020:17:47:41 +0000" }, - "time":"24/Apr/2020:17:47:41 +0000" - }, - "isBase64Encoded":true, - "body":"SGVsbG8gV29ybGQh" - } - """ - + "isBase64Encoded":true, + "body":"SGVsbG8gV29ybGQh" + } + """ + let data = requestWithBase64Body.data(using: .utf8)! let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) - + let decodedBody = try request.decodeBody() let expectedBody = "Hello World!".data(using: .utf8) #expect(decodedBody == expectedBody) @@ -314,11 +314,11 @@ struct APIGatewayV2Tests { let message: String let count: Int } - + // Use the fullExamplePayload which already has a simple JSON body let data = APIGatewayV2Tests.fullExamplePayload.data(using: .utf8)! let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) - + // Test that we can decode the body as String // The fullExamplePayload has body: "Hello from Lambda" which is not valid JSON, so this should fail #expect(throws: DecodingError.self) { @@ -330,47 +330,47 @@ struct APIGatewayV2Tests { struct TestPayload: Codable, Equatable { let messageText: String let count: Int - + enum CodingKeys: String, CodingKey { case messageText = "message_text" case count } } - + let testPayload = TestPayload(messageText: "test", count: 42) - + let requestWithBase64JSONBody = """ - { - "routeKey":"POST /hello", - "version":"2.0", - "rawPath":"/hello", - "rawQueryString":"", - "requestContext":{ - "timeEpoch":1587750461466, - "domainPrefix":"hello", - "accountId":"0123456789", - "stage":"$default", - "domainName":"hello.test.com", - "apiId":"pb5dg6g3rg", - "requestId":"LgLpnibOFiAEPCA=", - "http":{ - "path":"/hello", - "userAgent":"test", - "method":"POST", - "protocol":"HTTP/1.1", - "sourceIp":"127.0.0.1" + { + "routeKey":"POST /hello", + "version":"2.0", + "rawPath":"/hello", + "rawQueryString":"", + "requestContext":{ + "timeEpoch":1587750461466, + "domainPrefix":"hello", + "accountId":"0123456789", + "stage":"$default", + "domainName":"hello.test.com", + "apiId":"pb5dg6g3rg", + "requestId":"LgLpnibOFiAEPCA=", + "http":{ + "path":"/hello", + "userAgent":"test", + "method":"POST", + "protocol":"HTTP/1.1", + "sourceIp":"127.0.0.1" + }, + "time":"24/Apr/2020:17:47:41 +0000" }, - "time":"24/Apr/2020:17:47:41 +0000" - }, - "isBase64Encoded":true, - "body":"eyJtZXNzYWdlX3RleHQiOiJ0ZXN0IiwiY291bnQiOjQyfQ==", - "headers":{} - } - """ - + "isBase64Encoded":true, + "body":"eyJtZXNzYWdlX3RleHQiOiJ0ZXN0IiwiY291bnQiOjQyfQ==", + "headers":{} + } + """ + let data = requestWithBase64JSONBody.data(using: .utf8)! let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) - + let decodedPayload = try request.decodeBody(TestPayload.self) #expect(decodedPayload == testPayload) } diff --git a/Tests/AWSLambdaEventsTests/CodableHelpersTests.swift b/Tests/AWSLambdaEventsTests/CodableHelpersTests.swift new file mode 100644 index 0000000..f226b70 --- /dev/null +++ b/Tests/AWSLambdaEventsTests/CodableHelpersTests.swift @@ -0,0 +1,504 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import HTTPTypes +import Testing + +@testable import AWSLambdaEvents + +/// Tests for the DecodableRequest and EncodableResponse protocols +struct CodableHelpersTests { + + // MARK: - DecodableRequest Tests + + @Test func decodableRequestProtocolConformanceAPIGatewayV2() throws { + // Test that APIGatewayV2Request conforms to DecodableRequest protocol + let requestJSON = """ + { + "routeKey": "GET /test", + "version": "2.0", + "rawPath": "/test", + "rawQueryString": "", + "requestContext": { + "timeEpoch": 1587750461466, + "domainPrefix": "hello", + "accountId": "0123456789", + "stage": "$default", + "domainName": "hello.test.com", + "apiId": "pb5dg6g3rg", + "requestId": "LgLpnibOFiAEPCA=", + "http": { + "path": "/test", + "userAgent": "test", + "method": "GET", + "protocol": "HTTP/1.1", + "sourceIp": "127.0.0.1" + }, + "time": "24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded": false, + "body": null + } + """ + + let data = requestJSON.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + // Test protocol conformance through polymorphism + let decodableRequest: DecodableRequest = request + let bodyData = try decodableRequest.decodeBody() + #expect(bodyData == nil) // Since body is null + } + + @Test func decodableRequestProtocolConformanceFunctionURL() throws { + // Test that FunctionURLRequest conforms to DecodableRequest protocol + let requestJSON = """ + { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/test", + "rawQueryString": "", + "headers": {}, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "domainName": ".lambda-url.us-west-2.on.aws", + "domainPrefix": "", + "http": { + "method": "GET", + "path": "/test", + "protocol": "HTTP/1.1", + "sourceIp": "123.123.123.123", + "userAgent": "test" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": null, + "isBase64Encoded": false + } + """ + + let data = requestJSON.data(using: .utf8)! + let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) + + // Test protocol conformance through polymorphism + let decodableRequest: DecodableRequest = request + let bodyData = try decodableRequest.decodeBody() + #expect(bodyData == nil) // Since body is null + } + + @Test func decodableRequestDecodeBodyWithPlainText() throws { + let requestJSON = """ + { + "routeKey": "POST /test", + "version": "2.0", + "rawPath": "/test", + "rawQueryString": "", + "requestContext": { + "timeEpoch": 1587750461466, + "domainPrefix": "hello", + "accountId": "0123456789", + "stage": "$default", + "domainName": "hello.test.com", + "apiId": "pb5dg6g3rg", + "requestId": "LgLpnibOFiAEPCA=", + "http": { + "path": "/test", + "userAgent": "test", + "method": "POST", + "protocol": "HTTP/1.1", + "sourceIp": "127.0.0.1" + }, + "time": "24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded": false, + "body": "Hello, World!" + } + """ + + let data = requestJSON.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + let decodedBody = try request.decodeBody() + let expectedBody = "Hello, World!".data(using: .utf8) + #expect(decodedBody == expectedBody) + } + + @Test func decodableRequestDecodeBodyWithBase64() throws { + let requestJSON = """ + { + "routeKey": "POST /test", + "version": "2.0", + "rawPath": "/test", + "rawQueryString": "", + "requestContext": { + "timeEpoch": 1587750461466, + "domainPrefix": "hello", + "accountId": "0123456789", + "stage": "$default", + "domainName": "hello.test.com", + "apiId": "pb5dg6g3rg", + "requestId": "LgLpnibOFiAEPCA=", + "http": { + "path": "/test", + "userAgent": "test", + "method": "POST", + "protocol": "HTTP/1.1", + "sourceIp": "127.0.0.1" + }, + "time": "24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded": true, + "body": "SGVsbG8sIFdvcmxkIQ==" + } + """ + + let data = requestJSON.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + let decodedBody = try request.decodeBody() + let expectedBody = "Hello, World!".data(using: .utf8) + #expect(decodedBody == expectedBody) + } + + @Test func decodableRequestDecodeBodyAsDecodableType() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "test", count: 42) + + let requestJSON = """ + { + "routeKey": "POST /test", + "version": "2.0", + "rawPath": "/test", + "rawQueryString": "", + "requestContext": { + "timeEpoch": 1587750461466, + "domainPrefix": "hello", + "accountId": "0123456789", + "stage": "$default", + "domainName": "hello.test.com", + "apiId": "pb5dg6g3rg", + "requestId": "LgLpnibOFiAEPCA=", + "http": { + "path": "/test", + "userAgent": "test", + "method": "POST", + "protocol": "HTTP/1.1", + "sourceIp": "127.0.0.1" + }, + "time": "24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded": false, + "body": "{\\"message\\":\\"test\\",\\"count\\":42}" + } + """ + + let data = requestJSON.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + let decodedPayload = try request.decodeBody(TestPayload.self) + #expect(decodedPayload == testPayload) + } + + @Test func decodableRequestDecodeBodyAsDecodableTypeWithBase64() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "test", count: 42) + let jsonData = try JSONEncoder().encode(testPayload) + let base64String = jsonData.base64EncodedString() + + let requestJSON = """ + { + "routeKey": "POST /test", + "version": "2.0", + "rawPath": "/test", + "rawQueryString": "", + "requestContext": { + "timeEpoch": 1587750461466, + "domainPrefix": "hello", + "accountId": "0123456789", + "stage": "$default", + "domainName": "hello.test.com", + "apiId": "pb5dg6g3rg", + "requestId": "LgLpnibOFiAEPCA=", + "http": { + "path": "/test", + "userAgent": "test", + "method": "POST", + "protocol": "HTTP/1.1", + "sourceIp": "127.0.0.1" + }, + "time": "24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded": true, + "body": "\(base64String)" + } + """ + + let data = requestJSON.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + let decodedPayload = try request.decodeBody(TestPayload.self) + #expect(decodedPayload == testPayload) + } + + // MARK: - EncodableResponse Tests + + @Test func encodableResponseProtocolConformanceAPIGatewayV2() { + struct TestPayload: Codable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "test", count: 42) + + // Test that APIGatewayV2Response conforms to EncodableResponse protocol + let response = APIGatewayV2Response.encoding(testPayload, onError: APIGatewayV2Response.defaultErrorHandler) + + #expect(response.statusCode == .ok) + #expect(response.body != nil) + } + + @Test func encodableResponseProtocolConformanceFunctionURL() { + struct TestPayload: Codable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "test", count: 42) + + // Test that FunctionURLResponse conforms to EncodableResponse protocol + let response = FunctionURLResponse.encoding(testPayload, onError: FunctionURLResponse.defaultErrorHandler) + + #expect(response.statusCode == .ok) + #expect(response.body != nil) + } + + @Test func encodableResponseEncodingWithDefaultParameters() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "Hello", count: 123) + + let response = APIGatewayV2Response.encoding(testPayload) + + #expect(response.statusCode == .ok) + #expect(response.body != nil) + #expect(response.headers == nil) + #expect(response.cookies == nil) + #expect(response.isBase64Encoded == nil) + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedPayload = try JSONDecoder().decode(TestPayload.self, from: bodyData) + #expect(decodedPayload == testPayload) + } + + @Test func encodableResponseEncodingWithCustomStatus() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "Created", count: 201) + + let response = APIGatewayV2Response.encoding(testPayload, status: .created) + + #expect(response.statusCode == .created) + #expect(response.body != nil) + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedPayload = try JSONDecoder().decode(TestPayload.self, from: bodyData) + #expect(decodedPayload == testPayload) + } + + @Test func encodableResponseEncodingWithCustomEncoder() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "Pretty", count: 42) + let customEncoder = JSONEncoder() + customEncoder.outputFormatting = .prettyPrinted + + let response = APIGatewayV2Response.encoding(testPayload, using: customEncoder) + + #expect(response.statusCode == .ok) + #expect(response.body != nil) + #expect(response.body?.contains("\n") == true) // Pretty printed JSON should contain newlines + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedPayload = try JSONDecoder().decode(TestPayload.self, from: bodyData) + #expect(decodedPayload == testPayload) + } + + @Test func encodableResponseEncodingWithHeadersAndCookies() throws { + struct TestPayload: Codable, Equatable { + let message: String + let count: Int + } + + let testPayload = TestPayload(message: "WithHeaders", count: 200) + let headers = ["Content-Type": "application/json", "X-Custom-Header": "CustomValue"] + let cookies = ["session=abc123", "token=xyz789"] + + let response = APIGatewayV2Response.encoding( + testPayload, + status: .ok, + headers: headers, + cookies: cookies + ) + + #expect(response.statusCode == .ok) + #expect(response.body != nil) + #expect(response.headers == headers) + #expect(response.cookies == cookies) + + let bodyData = try #require(response.body?.data(using: .utf8)) + let decodedPayload = try JSONDecoder().decode(TestPayload.self, from: bodyData) + #expect(decodedPayload == testPayload) + } + + @Test func encodableResponseEncodingWithErrorHandler() { + struct FailingEncoder: Encodable { + func encode(to encoder: Encoder) throws { + throw TestError.encodingFailed + } + } + + enum TestError: Error { + case encodingFailed + } + + let failingObject = FailingEncoder() + + let response = APIGatewayV2Response.encoding(failingObject) { error in + APIGatewayV2Response( + statusCode: .badRequest, + headers: ["X-Error": "Custom"], + body: "Custom error: \(error)", + isBase64Encoded: false, + cookies: nil + ) + } + + #expect(response.statusCode == .badRequest) + #expect(response.headers?["X-Error"] == "Custom") + #expect(response.body?.contains("Custom error:") == true) + } + + @Test func encodableResponseDefaultErrorHandler() { + struct FailingEncoder: Encodable { + func encode(to encoder: Encoder) throws { + throw TestError.encodingFailed + } + } + + enum TestError: Error { + case encodingFailed + } + + let failingObject = FailingEncoder() + + let response = APIGatewayV2Response.encoding(failingObject) + + #expect(response.statusCode == .internalServerError) + #expect(response.body?.contains("Internal Server Error:") == true) + #expect(response.body?.contains("encodingFailed") == true) + } + + // MARK: - Cross-Protocol Integration Tests + + @Test func decodableRequestAndEncodableResponseIntegration() throws { + struct RequestPayload: Codable, Equatable { + let message: String + let count: Int + } + + struct ResponsePayload: Codable, Equatable { + let processedMessage: String + let doubledCount: Int + } + + // Create a request with a JSON payload + let requestPayload = RequestPayload(message: "process me", count: 21) + + let fullRequestJSON = """ + { + "routeKey": "POST /process", + "version": "2.0", + "rawPath": "/process", + "rawQueryString": "", + "requestContext": { + "timeEpoch": 1587750461466, + "domainPrefix": "hello", + "accountId": "0123456789", + "stage": "$default", + "domainName": "hello.test.com", + "apiId": "pb5dg6g3rg", + "requestId": "LgLpnibOFiAEPCA=", + "http": { + "path": "/process", + "userAgent": "test", + "method": "POST", + "protocol": "HTTP/1.1", + "sourceIp": "127.0.0.1" + }, + "time": "24/Apr/2020:17:47:41 +0000" + }, + "isBase64Encoded": false, + "body": "{\\"message\\":\\"process me\\",\\"count\\":21}" + } + """ + + let data = fullRequestJSON.data(using: .utf8)! + let request = try JSONDecoder().decode(APIGatewayV2Request.self, from: data) + + // Decode the request + let decodedRequest = try request.decodeBody(RequestPayload.self) + #expect(decodedRequest == requestPayload) + + // Process and create response + let responsePayload = ResponsePayload( + processedMessage: decodedRequest.message.uppercased(), + doubledCount: decodedRequest.count * 2 + ) + + // Encode the response + let response = APIGatewayV2Response.encoding(responsePayload, status: .ok) + + #expect(response.statusCode == .ok) + #expect(response.body != nil) + + // Verify the response content + let responseBodyData = try #require(response.body?.data(using: .utf8)) + let decodedResponse = try JSONDecoder().decode(ResponsePayload.self, from: responseBodyData) + #expect(decodedResponse.processedMessage == "PROCESS ME") + #expect(decodedResponse.doubledCount == 42) + } +} diff --git a/Tests/AWSLambdaEventsTests/FunctionURLTests.swift b/Tests/AWSLambdaEventsTests/FunctionURLTests.swift index cdd8116..8e9ee26 100644 --- a/Tests/AWSLambdaEventsTests/FunctionURLTests.swift +++ b/Tests/AWSLambdaEventsTests/FunctionURLTests.swift @@ -156,7 +156,7 @@ struct FunctionURLTests { @Test func decodeBodyWithNilBody() throws { let data = Self.realWorldExample.data(using: .utf8)! let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) - + let decodedBody = try request.decodeBody() #expect(decodedBody == nil) } @@ -164,7 +164,7 @@ struct FunctionURLTests { @Test func decodeBodyWithPlainTextBody() throws { let data = Self.documentationExample.data(using: .utf8)! let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) - + let decodedBody = try request.decodeBody() let expectedBody = "Hello from client!".data(using: .utf8) #expect(decodedBody == expectedBody) @@ -172,38 +172,38 @@ struct FunctionURLTests { @Test func decodeBodyWithBase64EncodedBody() throws { let requestWithBase64Body = """ - { - "version": "2.0", - "routeKey": "$default", - "rawPath": "/test", - "rawQueryString": "", - "headers": {}, - "requestContext": { - "accountId": "123456789012", - "apiId": "", - "domainName": ".lambda-url.us-west-2.on.aws", - "domainPrefix": "", - "http": { - "method": "POST", - "path": "/test", - "protocol": "HTTP/1.1", - "sourceIp": "123.123.123.123", - "userAgent": "test" - }, - "requestId": "id", + { + "version": "2.0", "routeKey": "$default", - "stage": "$default", - "time": "12/Mar/2020:19:03:58 +0000", - "timeEpoch": 1583348638390 - }, - "body": "SGVsbG8gZnJvbSBjbGllbnQh", - "isBase64Encoded": true - } - """ - + "rawPath": "/test", + "rawQueryString": "", + "headers": {}, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "domainName": ".lambda-url.us-west-2.on.aws", + "domainPrefix": "", + "http": { + "method": "POST", + "path": "/test", + "protocol": "HTTP/1.1", + "sourceIp": "123.123.123.123", + "userAgent": "test" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": "SGVsbG8gZnJvbSBjbGllbnQh", + "isBase64Encoded": true + } + """ + let data = requestWithBase64Body.data(using: .utf8)! let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) - + let decodedBody = try request.decodeBody() let expectedBody = "Hello from client!".data(using: .utf8) #expect(decodedBody == expectedBody) @@ -213,7 +213,7 @@ struct FunctionURLTests { // Use the documentationExample which already has a simple string body let data = Self.documentationExample.data(using: .utf8)! let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) - + // Test that we can decode the body as String // The documentationExample has body: "Hello from client!" which is not valid JSON, so this should fail #expect(throws: DecodingError.self) { @@ -226,163 +226,43 @@ struct FunctionURLTests { let message: String let count: Int } - + let testPayload = TestPayload(message: "test", count: 42) - + let requestWithBase64JSONBody = """ - { - "version": "2.0", - "routeKey": "$default", - "rawPath": "/test", - "rawQueryString": "", - "headers": {}, - "requestContext": { - "accountId": "123456789012", - "apiId": "", - "domainName": ".lambda-url.us-west-2.on.aws", - "domainPrefix": "", - "http": { - "method": "POST", - "path": "/test", - "protocol": "HTTP/1.1", - "sourceIp": "123.123.123.123", - "userAgent": "test" - }, - "requestId": "id", + { + "version": "2.0", "routeKey": "$default", - "stage": "$default", - "time": "12/Mar/2020:19:03:58 +0000", - "timeEpoch": 1583348638390 - }, - "body": "eyJtZXNzYWdlIjoidGVzdCIsImNvdW50Ijo0Mn0=", - "isBase64Encoded": true - } - """ - + "rawPath": "/test", + "rawQueryString": "", + "headers": {}, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "domainName": ".lambda-url.us-west-2.on.aws", + "domainPrefix": "", + "http": { + "method": "POST", + "path": "/test", + "protocol": "HTTP/1.1", + "sourceIp": "123.123.123.123", + "userAgent": "test" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": "eyJtZXNzYWdlIjoidGVzdCIsImNvdW50Ijo0Mn0=", + "isBase64Encoded": true + } + """ + let data = requestWithBase64JSONBody.data(using: .utf8)! let request = try JSONDecoder().decode(FunctionURLRequest.self, from: data) - + let decodedPayload = try request.decodeBody(TestPayload.self) #expect(decodedPayload == testPayload) } - - // MARK: FunctionURL Encoding Helper Tests - - @Test - func testFunctionURLResponseEncodingHelper() throws { - struct BusinessResponse: Codable, Equatable { - let message: String - let code: Int - } - - // given - let businessResponse = BusinessResponse(message: "Hello World", code: 200) - - // when - let response = FunctionURLResponse.encoding(businessResponse) - - // then - #expect(response.statusCode == .ok) - #expect(response.body != nil) - - let bodyData = try #require(response.body?.data(using: .utf8)) - let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) - #expect(decodedResponse == businessResponse) - } - - @Test - func testFunctionURLResponseEncodingHelperWithCustomStatus() throws { - struct BusinessResponse: Codable, Equatable { - let message: String - let code: Int - } - - // given - let businessResponse = BusinessResponse(message: "Created", code: 201) - - // when - let response = FunctionURLResponse.encoding(businessResponse, status: .created) - - // then - #expect(response.statusCode == .created) - #expect(response.body != nil) - - let bodyData = try #require(response.body?.data(using: .utf8)) - let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) - #expect(decodedResponse == businessResponse) - } - - @Test - func testFunctionURLResponseEncodingHelperWithCustomEncoder() throws { - struct BusinessResponse: Codable, Equatable { - let message: String - let code: Int - } - - // given - let businessResponse = BusinessResponse(message: "Hello World", code: 200) - let customEncoder = JSONEncoder() - customEncoder.outputFormatting = .prettyPrinted - - // when - let response = FunctionURLResponse.encoding(businessResponse, using: customEncoder) - - // then - #expect(response.statusCode == .ok) - #expect(response.body != nil) - #expect(response.body?.contains("\n") == true) // Pretty printed JSON should contain newlines - - let bodyData = try #require(response.body?.data(using: .utf8)) - let decodedResponse = try JSONDecoder().decode(BusinessResponse.self, from: bodyData) - #expect(decodedResponse == businessResponse) - } - - @Test - func testFunctionURLResponseEncodingHelperWithError() throws { - // given - struct InvalidEncodable: Encodable { - func encode(to encoder: Encoder) throws { - throw TestError.encodingFailed - } - } - - enum TestError: Error { - case encodingFailed - } - - let invalidObject = InvalidEncodable() - - // when - let response = FunctionURLResponse.encoding(invalidObject) - - // then - #expect(response.statusCode == .internalServerError) - #expect(response.body?.contains("Internal Server Error") == true) - } - - @Test - func testFunctionURLResponseEncodingHelperWithCustomErrorHandler() throws { - // given - struct InvalidEncodable: Encodable { - func encode(to encoder: Encoder) throws { - throw TestError.encodingFailed - } - } - - enum TestError: Error { - case encodingFailed - } - - let invalidObject = InvalidEncodable() - let customErrorHandler: (Error) -> FunctionURLResponse = { _ in - FunctionURLResponse(statusCode: .badRequest, body: "Custom error message") - } - - // when - let response = FunctionURLResponse.encoding(invalidObject, onError: customErrorHandler) - - // then - #expect(response.statusCode == .badRequest) - #expect(response.body == "Custom error message") - } } diff --git a/Tests/AWSLambdaEventsTests/SQSTests.swift b/Tests/AWSLambdaEventsTests/SQSTests.swift index f71651f..b0bf3ae 100644 --- a/Tests/AWSLambdaEventsTests/SQSTests.swift +++ b/Tests/AWSLambdaEventsTests/SQSTests.swift @@ -96,40 +96,40 @@ struct SQSTests { let message: String let count: Int } - + let testPayload = TestPayload(message: "test", count: 42) - + let eventBodyWithJSON = """ - { - "Records": [ { - "messageId": "test-message-id", - "receiptHandle": "test-receipt-handle", - "body": "{\\"message\\":\\"test\\",\\"count\\":42}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1523232000000", - "SenderId": "123456789012", - "ApproximateFirstReceiveTimestamp": "1523232000001" - }, - "messageAttributes": {}, - "md5OfBody": "test-md5", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", - "awsRegion": "us-east-1" + "Records": [ + { + "messageId": "test-message-id", + "receiptHandle": "test-receipt-handle", + "body": "{\\"message\\":\\"test\\",\\"count\\":42}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "test-md5", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", + "awsRegion": "us-east-1" + } + ] } - ] - } - """ - + """ + let data = eventBodyWithJSON.data(using: .utf8)! let event = try JSONDecoder().decode(SQSEvent.self, from: data) - + guard let message = event.records.first else { Issue.record("Expected to have one message in the event") return } - + let decodedPayload = try message.decodeBody(TestPayload.self) #expect(decodedPayload == testPayload) } @@ -139,52 +139,52 @@ struct SQSTests { let message: String let count: Int } - + let testPayload1 = TestPayload(message: "test1", count: 42) let testPayload2 = TestPayload(message: "test2", count: 84) - + let eventBodyWithMultipleRecords = """ - { - "Records": [ - { - "messageId": "test-message-id-1", - "receiptHandle": "test-receipt-handle-1", - "body": "{\\"message\\":\\"test1\\",\\"count\\":42}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1523232000000", - "SenderId": "123456789012", - "ApproximateFirstReceiveTimestamp": "1523232000001" - }, - "messageAttributes": {}, - "md5OfBody": "test-md5-1", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", - "awsRegion": "us-east-1" - }, { - "messageId": "test-message-id-2", - "receiptHandle": "test-receipt-handle-2", - "body": "{\\"message\\":\\"test2\\",\\"count\\":84}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1523232000000", - "SenderId": "123456789012", - "ApproximateFirstReceiveTimestamp": "1523232000001" - }, - "messageAttributes": {}, - "md5OfBody": "test-md5-2", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", - "awsRegion": "us-east-1" + "Records": [ + { + "messageId": "test-message-id-1", + "receiptHandle": "test-receipt-handle-1", + "body": "{\\"message\\":\\"test1\\",\\"count\\":42}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "test-md5-1", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", + "awsRegion": "us-east-1" + }, + { + "messageId": "test-message-id-2", + "receiptHandle": "test-receipt-handle-2", + "body": "{\\"message\\":\\"test2\\",\\"count\\":84}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "test-md5-2", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", + "awsRegion": "us-east-1" + } + ] } - ] - } - """ - + """ + let data = eventBodyWithMultipleRecords.data(using: .utf8)! let event = try JSONDecoder().decode(SQSEvent.self, from: data) - + let decodedPayloads = try event.decodeBody(TestPayload.self) #expect(decodedPayloads.count == 2) #expect(decodedPayloads[0] == testPayload1) @@ -195,43 +195,43 @@ struct SQSTests { struct TestPayload: Codable, Equatable { let messageText: String let count: Int - + enum CodingKeys: String, CodingKey { case messageText = "message_text" case count } } - + let testPayload = TestPayload(messageText: "test", count: 42) - + // We need to create a decoder that can handle the explicit coding keys - + let eventBodyWithSnakeCase = """ - { - "Records": [ { - "messageId": "test-message-id", - "receiptHandle": "test-receipt-handle", - "body": "{\\"message_text\\":\\"test\\",\\"count\\":42}", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1523232000000", - "SenderId": "123456789012", - "ApproximateFirstReceiveTimestamp": "1523232000001" - }, - "messageAttributes": {}, - "md5OfBody": "test-md5", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", - "awsRegion": "us-east-1" + "Records": [ + { + "messageId": "test-message-id", + "receiptHandle": "test-receipt-handle", + "body": "{\\"message_text\\":\\"test\\",\\"count\\":42}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "test-md5", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:TestQueue", + "awsRegion": "us-east-1" + } + ] } - ] - } - """ - + """ + let data = eventBodyWithSnakeCase.data(using: .utf8)! let event = try JSONDecoder().decode(SQSEvent.self, from: data) - + let decodedPayloads = try event.decodeBody(TestPayload.self) #expect(decodedPayloads.count == 1) #expect(decodedPayloads[0] == testPayload) From cbc54183a02547b4c2bb443d56b55a564d0a81b7 Mon Sep 17 00:00:00 2001 From: Natan Rolnik Date: Wed, 6 Aug 2025 16:55:50 +0300 Subject: [PATCH 4/4] More PR comments; rebase on top of #91 --- Sources/AWSLambdaEvents/APIGateway+V2.swift | 4 +++ .../Codable Helpers/DecodableRequest.swift | 3 -- .../Codable Helpers/EncodableResponse.swift | 31 ++++--------------- Sources/AWSLambdaEvents/FunctionURL.swift | 4 +++ 4 files changed, 14 insertions(+), 28 deletions(-) diff --git a/Sources/AWSLambdaEvents/APIGateway+V2.swift b/Sources/AWSLambdaEvents/APIGateway+V2.swift index 42cf355..00f9e8c 100644 --- a/Sources/AWSLambdaEvents/APIGateway+V2.swift +++ b/Sources/AWSLambdaEvents/APIGateway+V2.swift @@ -126,6 +126,8 @@ public struct APIGatewayV2Request: Encodable, Sendable { } } +extension APIGatewayV2Request: DecodableRequest {} + public struct APIGatewayV2Response: Codable, Sendable { public var statusCode: HTTPResponse.Status public var headers: HTTPHeaders? @@ -148,6 +150,8 @@ public struct APIGatewayV2Response: Codable, Sendable { } } +extension APIGatewayV2Response: EncodableResponse {} + extension APIGatewayV2Request: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/Sources/AWSLambdaEvents/Codable Helpers/DecodableRequest.swift b/Sources/AWSLambdaEvents/Codable Helpers/DecodableRequest.swift index 592ee62..f5a253a 100644 --- a/Sources/AWSLambdaEvents/Codable Helpers/DecodableRequest.swift +++ b/Sources/AWSLambdaEvents/Codable Helpers/DecodableRequest.swift @@ -73,6 +73,3 @@ extension DecodableRequest { return try decoder.decode(T.self, from: requestData) } } - -extension APIGatewayV2Request: DecodableRequest {} -extension FunctionURLRequest: DecodableRequest {} diff --git a/Sources/AWSLambdaEvents/Codable Helpers/EncodableResponse.swift b/Sources/AWSLambdaEvents/Codable Helpers/EncodableResponse.swift index 3bfb6fd..d2f400f 100644 --- a/Sources/AWSLambdaEvents/Codable Helpers/EncodableResponse.swift +++ b/Sources/AWSLambdaEvents/Codable Helpers/EncodableResponse.swift @@ -34,8 +34,8 @@ public protocol EncodableResponse { statusCode: HTTPResponse.Status, headers: HTTPHeaders?, body: String?, - cookies: [String]?, - isBase64Encoded: Bool? + isBase64Encoded: Bool?, + cookies: [String]? ) } @@ -66,8 +66,8 @@ extension EncodableResponse { statusCode: status, headers: headers, body: String(data: encodedResponse, encoding: .utf8), - cookies: cookies, - isBase64Encoded: nil + isBase64Encoded: nil, + cookies: cookies ) } catch { return onError(error) @@ -80,28 +80,9 @@ extension EncodableResponse { statusCode: .internalServerError, headers: nil, body: "Internal Server Error: \(String(describing: error))", - cookies: nil, - isBase64Encoded: nil + isBase64Encoded: nil, + cookies: nil ) } } } - -extension APIGatewayV2Response: EncodableResponse { - public init( - statusCode: HTTPResponse.Status, - headers: HTTPHeaders?, - body: String?, - cookies: [String]?, - isBase64Encoded: Bool? - ) { - self.init( - statusCode: statusCode, - headers: headers, - body: body, - isBase64Encoded: isBase64Encoded, - cookies: cookies - ) - } -} -extension FunctionURLResponse: EncodableResponse {} diff --git a/Sources/AWSLambdaEvents/FunctionURL.swift b/Sources/AWSLambdaEvents/FunctionURL.swift index e3f3774..c99390d 100644 --- a/Sources/AWSLambdaEvents/FunctionURL.swift +++ b/Sources/AWSLambdaEvents/FunctionURL.swift @@ -83,6 +83,8 @@ public struct FunctionURLRequest: Codable, Sendable { public let stageVariables: [String: String]? } +extension FunctionURLRequest: DecodableRequest {} + // MARK: - Response - public struct FunctionURLResponse: Codable, Sendable { @@ -121,3 +123,5 @@ public struct FunctionURLResponse: Codable, Sendable { self.isBase64Encoded = isBase64Encoded } } + +extension FunctionURLResponse: EncodableResponse {}