From 21eaecddae81fb91ccd0b68ad7cb40a635436641 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Mon, 2 Dec 2019 17:35:31 +0100 Subject: [PATCH] More Events. Added the following Event types: - ALB Event - Cloudwatch Event - SQS Event - SNS Event - DynamoDB Event Improvements to: - APIGateway Added multi purpose type: - Added AWSNumber (Thanks for the idea @ro-m.) --- .gitignore | 3 +- .../xcshareddata/xcschemes/AWSLambda.xcscheme | 92 ------ Examples/EventSources/Package.resolved | 61 ++++ Examples/EventSources/Package.swift | 18 ++ Examples/EventSources/README.md | 3 + .../Sources/EventSources/main.swift | 119 ++++++++ Examples/EventSources/template.yaml | 275 ++++++++++++++++++ Examples/SquareNumber/Package.resolved | 9 + Examples/TodoAPIGateway/Package.resolved | 9 + Package.swift | 2 +- README.md | 2 +- Sources/LambdaRuntime/AWSNumber.swift | 32 ++ Sources/LambdaRuntime/Context.swift | 3 +- Sources/LambdaRuntime/Events/ALB.swift | 245 ++++++++++++++++ Sources/LambdaRuntime/Events/APIGateway.swift | 77 ++++- Sources/LambdaRuntime/Events/Cloudwatch.swift | 60 ++++ .../Events/DynamoDB+AttributeValue.swift | 130 +++++++++ Sources/LambdaRuntime/Events/DynamoDB.swift | 175 +++++++++++ Sources/LambdaRuntime/Events/SNS.swift | 172 +++++++++++ Sources/LambdaRuntime/Events/SQS.swift | 111 +++++++ Sources/LambdaRuntime/Runtime+Codable.swift | 10 +- Sources/LambdaRuntime/Runtime.swift | 4 +- .../Utils/HTTPHeaders+Codable.swift | 15 + Tests/LambdaRuntimeTests/AWSNumberTests.swift | 74 +++++ .../LambdaRuntimeTests/Events/ALBTests.swift | 134 +++++++++ .../Events/APIGatewayTests.swift | 13 +- .../Events/CloudwatchTests.swift | 40 +++ .../Events/DynamoDB+AttributeValueTests.swift | 118 ++++++++ .../Events/DynamoDBTests.swift | 115 ++++++++ .../LambdaRuntimeTests/Events/SNSTests.swift | 83 ++++++ .../LambdaRuntimeTests/Events/SQSTests.swift | 86 ++++++ .../Utils/MockLambdaRuntimeAPI.swift | 7 - 32 files changed, 2165 insertions(+), 132 deletions(-) delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/AWSLambda.xcscheme create mode 100644 Examples/EventSources/Package.resolved create mode 100644 Examples/EventSources/Package.swift create mode 100644 Examples/EventSources/README.md create mode 100644 Examples/EventSources/Sources/EventSources/main.swift create mode 100644 Examples/EventSources/template.yaml create mode 100644 Sources/LambdaRuntime/AWSNumber.swift create mode 100644 Sources/LambdaRuntime/Events/ALB.swift create mode 100644 Sources/LambdaRuntime/Events/Cloudwatch.swift create mode 100644 Sources/LambdaRuntime/Events/DynamoDB+AttributeValue.swift create mode 100644 Sources/LambdaRuntime/Events/DynamoDB.swift create mode 100644 Sources/LambdaRuntime/Events/SNS.swift create mode 100644 Sources/LambdaRuntime/Events/SQS.swift create mode 100644 Sources/LambdaRuntime/Utils/HTTPHeaders+Codable.swift create mode 100644 Tests/LambdaRuntimeTests/AWSNumberTests.swift create mode 100644 Tests/LambdaRuntimeTests/Events/ALBTests.swift create mode 100644 Tests/LambdaRuntimeTests/Events/CloudwatchTests.swift create mode 100644 Tests/LambdaRuntimeTests/Events/DynamoDB+AttributeValueTests.swift create mode 100644 Tests/LambdaRuntimeTests/Events/DynamoDBTests.swift create mode 100644 Tests/LambdaRuntimeTests/Events/SNSTests.swift create mode 100644 Tests/LambdaRuntimeTests/Events/SQSTests.swift diff --git a/.gitignore b/.gitignore index 4226f50..722a2c7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ xcuserdata Layer Examples/**/lambda.zip -packaged.yaml \ No newline at end of file +packaged.yaml +bootstrap \ No newline at end of file diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AWSLambda.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AWSLambda.xcscheme deleted file mode 100644 index 0a59884..0000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/AWSLambda.xcscheme +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Examples/EventSources/Package.resolved b/Examples/EventSources/Package.resolved new file mode 100644 index 0000000..cb5c554 --- /dev/null +++ b/Examples/EventSources/Package.resolved @@ -0,0 +1,61 @@ +{ + "object": { + "pins": [ + { + "package": "async-http-client", + "repositoryURL": "https://github.com/swift-server/async-http-client.git", + "state": { + "branch": null, + "revision": "51dc885a30ca704b02fa803099b0a9b5b38067b6", + "version": "1.0.0" + } + }, + { + "package": "swift-base64-kit", + "repositoryURL": "https://github.com/fabianfett/swift-base64-kit.git", + "state": { + "branch": null, + "revision": "3ffa48a7047fc9ac6581cd53ab1df29466d8f13b", + "version": "0.2.0" + } + }, + { + "package": "swift-log", + "repositoryURL": "https://github.com/apple/swift-log.git", + "state": { + "branch": null, + "revision": "74d7b91ceebc85daf387ebb206003f78813f71aa", + "version": "1.2.0" + } + }, + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "ff01888051cd7efceb1bf8319c1dd3986c4bf6fc", + "version": "2.10.1" + } + }, + { + "package": "swift-nio-extras", + "repositoryURL": "https://github.com/apple/swift-nio-extras.git", + "state": { + "branch": null, + "revision": "53808818c2015c45247cad74dc05c7a032c96a2f", + "version": "1.3.2" + } + }, + { + "package": "swift-nio-ssl", + "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", + "state": { + "branch": null, + "revision": "ccf96bbe65ecc7c1558ab0dba7ffabdea5c1d31f", + "version": "2.4.4" + } + } + ] + }, + "version": 1 +} diff --git a/Examples/EventSources/Package.swift b/Examples/EventSources/Package.swift new file mode 100644 index 0000000..4af465a --- /dev/null +++ b/Examples/EventSources/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version:5.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "EventSources", + dependencies: [ + .package(path: "../.."), + .package(url: "https://github.com/apple/swift-nio.git", .upToNextMajor(from: "2.9.0")), + .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.1.1")), + ], + targets: [ + .target( + name: "EventSources", + dependencies: ["LambdaRuntime", "Logging", "NIO", "NIOFoundationCompat", "NIOHTTP1"]), + ] +) diff --git a/Examples/EventSources/README.md b/Examples/EventSources/README.md new file mode 100644 index 0000000..383ecc2 --- /dev/null +++ b/Examples/EventSources/README.md @@ -0,0 +1,3 @@ +# EventSources + +This package/executable can be used to test the different Event types. This especially usefull during development of new Event types. We use the sam `template.yaml` to deploy the external resources needed for testing. Please be aware, if you deploy the given `template.yaml` costs may occur (especially when using the LoadBalancer). diff --git a/Examples/EventSources/Sources/EventSources/main.swift b/Examples/EventSources/Sources/EventSources/main.swift new file mode 100644 index 0000000..1d9bcf1 --- /dev/null +++ b/Examples/EventSources/Sources/EventSources/main.swift @@ -0,0 +1,119 @@ +import LambdaRuntime +import NIO +import Logging +import Foundation + +LoggingSystem.bootstrap(StreamLogHandler.standardError) + + +let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) +defer { try! group.syncShutdownGracefully() } +let logger = Logger(label: "AWSLambda.EventSources") + +func handleSNS(event: SNS.Event, ctx: Context) -> EventLoopFuture { + ctx.logger.info("Payload: \(String(describing: event))") + + return ctx.eventLoop.makeSucceededFuture(Void()) +} + +func handleSQS(event: SQS.Event, ctx: Context) -> EventLoopFuture { + ctx.logger.info("Payload: \(String(describing: event))") + + return ctx.eventLoop.makeSucceededFuture(Void()) +} + +func handleDynamoStream(event: DynamoDB.Event, ctx: Context) -> EventLoopFuture { + ctx.logger.info("Payload: \(String(describing: event))") + + return ctx.eventLoop.makeSucceededFuture(Void()) +} + +func handleCloudwatchSchedule(event: Cloudwatch.Event, ctx: Context) + -> EventLoopFuture +{ + ctx.logger.info("Payload: \(String(describing: event))") + + return ctx.eventLoop.makeSucceededFuture(Void()) +} + +func handleAPIRequest(req: APIGateway.Request, ctx: Context) -> EventLoopFuture { + ctx.logger.info("Payload: \(String(describing: req))") + + struct Payload: Encodable { + let path: String + let method: String + } + + let payload = Payload(path: req.path, method: req.httpMethod.rawValue) + let response = try! APIGateway.Response(statusCode: .ok, payload: payload) + + return ctx.eventLoop.makeSucceededFuture(response) +} + +func handleLoadBalancerRequest(req: ALB.TargetGroupRequest, ctx: Context) -> + EventLoopFuture +{ + ctx.logger.info("Payload: \(String(describing: req))") + + struct Payload: Encodable { + let path: String + let method: String + } + + let payload = Payload(path: req.path, method: req.httpMethod.rawValue) + let response = try! ALB.TargetGroupResponse(statusCode: .ok, payload: payload) + + return ctx.eventLoop.makeSucceededFuture(response) +} + +func printPayload(buffer: NIO.ByteBuffer, ctx: Context) -> EventLoopFuture { + let payload = buffer.getString(at: 0, length: buffer.readableBytes) + ctx.logger.error("Payload: \(String(describing: payload))") + + return ctx.eventLoop.makeSucceededFuture(nil) +} + +func printOriginalPayload(_ handler: @escaping (NIO.ByteBuffer, Context) -> EventLoopFuture) + -> ((NIO.ByteBuffer, Context) -> EventLoopFuture) +{ + return { (buffer, ctx) in + let payload = buffer.getString(at: 0, length: buffer.readableBytes) + ctx.logger.info("Payload: \(String(describing: payload))") + + return handler(buffer, ctx) + } +} + +do { + logger.info("start runtime") + let environment = try Environment() + let handler: LambdaRuntime.Handler + + switch environment.handlerName { + case "sns": + handler = printOriginalPayload(LambdaRuntime.codable(handleSNS)) + case "sqs": + handler = printOriginalPayload(LambdaRuntime.codable(handleSQS)) + case "dynamo": + handler = printOriginalPayload(LambdaRuntime.codable(handleDynamoStream)) + case "schedule": + handler = printOriginalPayload(LambdaRuntime.codable(handleCloudwatchSchedule)) + case "api": + handler = printOriginalPayload(APIGateway.handler(handleAPIRequest)) + case "loadbalancer": + handler = printOriginalPayload(ALB.handler(multiValueHeadersEnabled: true, handleLoadBalancerRequest)) + default: + handler = printPayload + } + + let runtime = try LambdaRuntime.createRuntime(eventLoopGroup: group, handler: handler) + defer { try! runtime.syncShutdown() } + logger.info("starting runloop") + + try runtime.start().wait() +} +catch { + logger.error("error: \(String(describing: error))") +} + + diff --git a/Examples/EventSources/template.yaml b/Examples/EventSources/template.yaml new file mode 100644 index 0000000..e1e6261 --- /dev/null +++ b/Examples/EventSources/template.yaml @@ -0,0 +1,275 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Parameters: + SwiftLayer: + Type: String + Description: The arn of the swift layer. + Default: arn:aws:lambda:eu-central-1:426836788079:layer:Swift:8 + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 3 + +Resources: + + # --- VPC + + VPC: + Type: "AWS::EC2::VPC" + Properties: + CidrBlock: 10.0.0.0/16 + EnableDnsSupport: true + EnableDnsHostnames: true + InstanceTenancy: default + Tags: + - Key: Name + Value: "Test-VPC" + + PublicSubnetCentralA: + Type: "AWS::EC2::Subnet" + Properties: + AvailabilityZone: eu-central-1a + CidrBlock: 10.0.0.0/24 + VpcId: !Ref VPC + + PublicSubnetCentralB: + Type: "AWS::EC2::Subnet" + Properties: + AvailabilityZone: eu-central-1b + CidrBlock: 10.0.10.0/24 + VpcId: !Ref VPC + + InternetGateway: + Type: "AWS::EC2::InternetGateway" + + InternetGatewayAttachToVPC: + Type: "AWS::EC2::VPCGatewayAttachment" + Properties: + InternetGatewayId: !Ref InternetGateway + VpcId: !Ref VPC + + PublicRouteTable: + Type: "AWS::EC2::RouteTable" + Properties: + VpcId: !Ref VPC + + InternetRouteForPublicSubnets: + Type: "AWS::EC2::Route" + Properties: + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: !Ref InternetGateway + RouteTableId: !Ref PublicRouteTable + + PublicSubnetCentralARouteTableAssociation: + Type: "AWS::EC2::SubnetRouteTableAssociation" + Properties: + RouteTableId: !Ref PublicRouteTable + SubnetId: !Ref PublicSubnetCentralA + + PublicSubnetCentralBRouteTableAssociation: + Type: "AWS::EC2::SubnetRouteTableAssociation" + Properties: + RouteTableId: !Ref PublicRouteTable + SubnetId: !Ref PublicSubnetCentralB + + # --- cloudwatch schedule + + ConsumeCloudwatchScheduleLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: lambda.zip + Handler: "schedule" + Runtime: provided + Layers: + - !Ref SwiftLayer + Events: + schedule: + Type: Schedule + Properties: + Schedule: rate(5 minutes) + Enabled: True + + # --- sns + + SNSTopic: + Type: AWS::SNS::Topic + + ConsumeSNSTopicLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: lambda.zip + Handler: "sns" + Runtime: provided + Layers: + - !Ref SwiftLayer + Policies: + - SNSCrudPolicy: + TopicName: !GetAtt SNSTopic.TopicName + Events: + sns: + Type: SNS + Properties: + Topic: !Ref SNSTopic + + # --- sqs + + SQSQueue: + Type: AWS::SQS::Queue + + ConsumeSQSQueueLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: lambda.zip + Handler: "sqs" + Runtime: provided + Layers: + - !Ref SwiftLayer + Policies: + - SQSPollerPolicy: + QueueName: !GetAtt SQSQueue.QueueName + Events: + sqs: + Type: SQS + Properties: + Queue: !GetAtt SQSQueue.Arn + BatchSize: 10 + Enabled: true + + # --- dynamo + + EventSourcesTestTable: + Type: "AWS::DynamoDB::Table" + Properties: + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: ListId + AttributeType: S + - AttributeName: TodoId + AttributeType: S + KeySchema: + - AttributeName: ListId + KeyType: HASH + - AttributeName: TodoId + KeyType: RANGE + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES + TableName: "EventSourcesTestTable" + + ConsumeDynamoDBStreamLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: lambda.zip + Handler: "dynamo" + Runtime: provided + Layers: + - !Ref SwiftLayer + Policies: + - SQSPollerPolicy: + QueueName: !GetAtt SQSQueue.QueueName + Events: + dynamo: + Type: DynamoDB + Properties: + Stream: !GetAtt EventSourcesTestTable.StreamArn + StartingPosition: TRIM_HORIZON + BatchSize: 10 + MaximumBatchingWindowInSeconds: 10 + Enabled: true + ParallelizationFactor: 8 + MaximumRetryAttempts: 100 + BisectBatchOnFunctionError: true + MaximumRecordAgeInSeconds: 86400 + + # --- api + + HandleAPIRequestLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: lambda.zip + Handler: "api" + Runtime: provided + Layers: + - !Ref SwiftLayer + Events: + api: + Type: Api + Properties: + Path: /{proxy+} + Method: ANY + + # --- load balancer + + HandleLoadBalancerLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: lambda.zip + Handler: "loadbalancer" + Runtime: provided + Layers: + - !Ref SwiftLayer + + HandleLoadBalancerLambdaInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !GetAtt HandleLoadBalancerLambda.Arn + Action: lambda:InvokeFunction + Principal: elasticloadbalancing.amazonaws.com + + TestLoadBalancerSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: External ELB Security Group + SecurityGroupIngress: + - CidrIp: 0.0.0.0/0 + FromPort: 80 + ToPort: 80 + IpProtocol: tcp + - CidrIp: 0.0.0.0/0 + FromPort: 443 + ToPort: 443 + IpProtocol: tcp + SecurityGroupEgress: + - CidrIp: 0.0.0.0/0 + IpProtocol: -1 + VpcId: !Ref VPC + + TestLoadBalancer: + Type: AWS::ElasticLoadBalancingV2::LoadBalancer + Properties: + Scheme: internet-facing + Type: application + Subnets: + - !Ref PublicSubnetCentralA + - !Ref PublicSubnetCentralB + SecurityGroups: + - !Ref TestLoadBalancerSecurityGroup + + TestLoadBalancerListener: + Type: "AWS::ElasticLoadBalancingV2::Listener" + Properties: + DefaultActions: + - Type: forward + TargetGroupArn: !Ref TestLoadBalancerTargetGroup + LoadBalancerArn: !Ref TestLoadBalancer + Port: 80 + Protocol: HTTP + + TestLoadBalancerTargetGroup: + Type: AWS::ElasticLoadBalancingV2::TargetGroup + DependsOn: + - HandleLoadBalancerLambdaInvokePermission + Properties: + Name: EinSternDerDeinenNamenTraegt + Targets: + - Id: !GetAtt HandleLoadBalancerLambda.Arn + TargetGroupAttributes: + - Key: lambda.multi_value_headers.enabled + Value: true + TargetType: lambda + + + + + + diff --git a/Examples/SquareNumber/Package.resolved b/Examples/SquareNumber/Package.resolved index 8a8262e..225ac00 100644 --- a/Examples/SquareNumber/Package.resolved +++ b/Examples/SquareNumber/Package.resolved @@ -10,6 +10,15 @@ "version": "1.0.0" } }, + { + "package": "swift-base64-kit", + "repositoryURL": "https://github.com/fabianfett/swift-base64-kit.git", + "state": { + "branch": null, + "revision": "3ffa48a7047fc9ac6581cd53ab1df29466d8f13b", + "version": "0.2.0" + } + }, { "package": "swift-log", "repositoryURL": "https://github.com/apple/swift-log.git", diff --git a/Examples/TodoAPIGateway/Package.resolved b/Examples/TodoAPIGateway/Package.resolved index a12c2c9..e27a38a 100644 --- a/Examples/TodoAPIGateway/Package.resolved +++ b/Examples/TodoAPIGateway/Package.resolved @@ -46,6 +46,15 @@ "version": "3.0.3" } }, + { + "package": "swift-base64-kit", + "repositoryURL": "https://github.com/fabianfett/swift-base64-kit.git", + "state": { + "branch": null, + "revision": "3ffa48a7047fc9ac6581cd53ab1df29466d8f13b", + "version": "0.2.0" + } + }, { "package": "swift-log", "repositoryURL": "https://github.com/apple/swift-log.git", diff --git a/Package.swift b/Package.swift index 0853c61..531495c 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( targets: [ .target( name: "LambdaRuntime", - dependencies: ["AsyncHTTPClient", "NIO", "NIOHTTP1", "NIOFoundationCompat", "Logging"] + dependencies: ["AsyncHTTPClient", "NIO", "NIOHTTP1", "NIOFoundationCompat", "Logging", "Base64Kit"] ), .testTarget(name: "LambdaRuntimeTests", dependencies: ["LambdaRuntime", "NIOTestUtils", "Logging"]) ] diff --git a/README.md b/README.md index 6f88f61..0a980b9 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Learn in depth how to build Lambdas in Swift in my blog series: - [x] Built on top of `Swift-NIO` - [x] Integration with Swift [`Logging`](https://github.com/apple/swift-log) -- [ ] Ready-to-use [AWS Events](https://github.com/fabianfett/swift-lambda-runtime/tree/master/Sources/LambdaRuntime/Events) structs to get started as fast as possible +- [x] Ready-to-use [AWS Events](https://github.com/fabianfett/swift-lambda-runtime/tree/master/Sources/LambdaRuntime/Events) structs to get started as fast as possible. Currently implemented: Application Load Balancer, APIGateway, Cloudwatch Scheduled Events, DynamoDB Streams, SNS and SQS Messages. More coming soon. - [x] [Tested integration](https://github.com/fabianfett/swift-lambda-runtime/blob/master/Examples/TodoAPIGateway/Sources/TodoAPIGateway/main.swift) with [`aws-swift-sdk`](https://github.com/swift-aws/aws-sdk-swift) - [x] [Two examples](https://github.com/fabianfett/swift-lambda-runtime/tree/master/Examples) to get you up and running as fast as possible (including an [API-Gateway Todo-List](http://todobackend.com/client/index.html?https://mwpixnkbzj.execute-api.eu-central-1.amazonaws.com/test/todos)) - [x] Unit and end-to-end tests diff --git a/Sources/LambdaRuntime/AWSNumber.swift b/Sources/LambdaRuntime/AWSNumber.swift new file mode 100644 index 0000000..c7a4225 --- /dev/null +++ b/Sources/LambdaRuntime/AWSNumber.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct AWSNumber: Codable, Equatable { + + public let stringValue: String + + public var int: Int? { + return Int(stringValue) + } + + public var double: Double? { + return Double(stringValue) + } + + public init(int: Int) { + stringValue = String(int) + } + + public init(double: Double) { + stringValue = String(double) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + stringValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(stringValue) + } +} diff --git a/Sources/LambdaRuntime/Context.swift b/Sources/LambdaRuntime/Context.swift index 71dce36..d100afc 100644 --- a/Sources/LambdaRuntime/Context.swift +++ b/Sources/LambdaRuntime/Context.swift @@ -18,8 +18,9 @@ public class Context { public init(environment: Environment, invocation: Invocation, eventLoop: EventLoop) { - var logger = Logger(label: "aws.lambda.swift.request-logger") + var logger = Logger(label: "AWSLambda.request-logger") logger[metadataKey: "RequestId"] = .string(invocation.requestId) + logger[metadataKey: "TraceId" ] = .string(invocation.traceId) self.environment = environment self.invocation = invocation diff --git a/Sources/LambdaRuntime/Events/ALB.swift b/Sources/LambdaRuntime/Events/ALB.swift new file mode 100644 index 0000000..00446ea --- /dev/null +++ b/Sources/LambdaRuntime/Events/ALB.swift @@ -0,0 +1,245 @@ +import Foundation +import NIO +import NIOHTTP1 + + +// https://github.com/aws/aws-lambda-go/blob/master/events/alb.go + +public struct ALB { + + /// ALBTargetGroupRequest contains data originating from the ALB Lambda target group integration + public struct TargetGroupRequest { + + /// ALBTargetGroupRequestContext contains the information to identify the load balancer invoking the lambda + public struct Context: Codable { + public let elb: ELBContext + } + + public let httpMethod: HTTPMethod + public let path: String + public let queryStringParameters: [String: [String]] + public let headers: HTTPHeaders + public let requestContext: Context + public let isBase64Encoded: Bool + public let body: String + } + + /// ELBContext contains the information to identify the ARN invoking the lambda + public struct ELBContext: Codable { + public let targetGroupArn: String + } + + public struct TargetGroupResponse { + + public let statusCode : HTTPResponseStatus + public let statusDescription: String? + public let headers : HTTPHeaders? + public let body : String + public let isBase64Encoded : Bool + + public init( + statusCode: HTTPResponseStatus, + statusDescription: String? = nil, + headers: HTTPHeaders? = nil, + body: String = "", + isBase64Encoded: Bool = false) + { + self.statusCode = statusCode + self.statusDescription = statusDescription + self.headers = headers + self.body = body + self.isBase64Encoded = isBase64Encoded + } + } +} + +// MARK: - Handler - + +extension ALB { + + public static func handler( + multiValueHeadersEnabled: Bool = false, + _ handler: @escaping (ALB.TargetGroupRequest, Context) -> EventLoopFuture) + -> ((NIO.ByteBuffer, Context) -> EventLoopFuture) + { + // reuse as much as possible + let encoder = JSONEncoder() + let decoder = JSONDecoder() + encoder.userInfo[ALB.TargetGroupResponse.MultiValueHeadersEnabledKey] = multiValueHeadersEnabled + + return { (inputBytes: NIO.ByteBuffer, ctx: Context) -> EventLoopFuture in + + let req: ALB.TargetGroupRequest + do { + req = try decoder.decode(ALB.TargetGroupRequest.self, from: inputBytes) + } + catch { + return ctx.eventLoop.makeFailedFuture(error) + } + + return handler(req, ctx) + .flatMapErrorThrowing() { (error) -> ALB.TargetGroupResponse in + ctx.logger.error("Unhandled error. Responding with HTTP 500: \(error).") + return ALB.TargetGroupResponse(statusCode: .internalServerError) + } + .flatMapThrowing { (result: ALB.TargetGroupResponse) -> NIO.ByteBuffer in + return try encoder.encodeAsByteBuffer(result, allocator: ByteBufferAllocator()) + } + } + } +} + +// MARK: - Request - + +extension ALB.TargetGroupRequest: Decodable { + + enum CodingKeys: String, CodingKey { + case httpMethod = "httpMethod" + case path = "path" + case queryStringParameters = "queryStringParameters" + case multiValueQueryStringParameters = "multiValueQueryStringParameters" + case headers = "headers" + case multiValueHeaders = "multiValueHeaders" + case requestContext = "requestContext" + case isBase64Encoded = "isBase64Encoded" + case body = "body" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let method = try container.decode(String.self, forKey: .httpMethod) + self.httpMethod = HTTPMethod(rawValue: method) + + self.path = try container.decode(String.self, forKey: .path) + + // crazy multiple headers + // https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers + + if let multiValueQueryStringParameters = + try container.decodeIfPresent([String: [String]].self, forKey: .multiValueQueryStringParameters) + { + self.queryStringParameters = multiValueQueryStringParameters + } + else { + let singleValueQueryStringParameters = try container.decode( + [String: String].self, + forKey: .queryStringParameters) + self.queryStringParameters = singleValueQueryStringParameters.mapValues { [$0] } + } + + if let multiValueHeaders = + try container.decodeIfPresent([String: [String]].self, forKey: .multiValueHeaders) + { + self.headers = HTTPHeaders(awsHeaders: multiValueHeaders) + } + else { + let singleValueHeaders = try container.decode( + [String: String].self, + forKey: .headers) + let multiValueHeaders = singleValueHeaders.mapValues { [$0] } + self.headers = HTTPHeaders(awsHeaders: multiValueHeaders) + } + + self.requestContext = try container.decode(Context.self, forKey: .requestContext) + self.isBase64Encoded = try container.decode(Bool.self, forKey: .isBase64Encoded) + self.body = try container.decode(String.self, forKey: .body) + } + +} + +// MARK: - Response - + +extension ALB.TargetGroupResponse: Encodable { + + internal static let MultiValueHeadersEnabledKey = + CodingUserInfoKey(rawValue: "ALB.TargetGroupResponse.MultiValueHeadersEnabledKey")! + + enum CodingKeys: String, CodingKey { + case statusCode + case statusDescription + case headers + case multiValueHeaders + case body + case isBase64Encoded + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(statusCode.code, forKey: .statusCode) + + let multiValueHeaderSupport = + encoder.userInfo[ALB.TargetGroupResponse.MultiValueHeadersEnabledKey] as? Bool ?? false + + switch (multiValueHeaderSupport, headers) { + case (true, .none): + try container.encode([String:String](), forKey: .multiValueHeaders) + case (false, .none): + try container.encode([String:[String]](), forKey: .headers) + case (true, .some(let headers)): + var multiValueHeaders: [String: [String]] = [:] + headers.forEach { (name, value) in + var values = multiValueHeaders[name] ?? [] + values.append(value) + multiValueHeaders[name] = values + } + try container.encode(multiValueHeaders, forKey: .multiValueHeaders) + case (false, .some(let headers)): + var singleValueHeaders: [String: String] = [:] + headers.forEach { (name, value) in + singleValueHeaders[name] = value + } + try container.encode(singleValueHeaders, forKey: .headers) + } + + try container.encodeIfPresent(statusDescription, forKey: .statusDescription) + try container.encodeIfPresent(body, forKey: .body) + try container.encodeIfPresent(isBase64Encoded, forKey: .isBase64Encoded) + } + +} + +extension ALB.TargetGroupResponse { + + public init( + statusCode : HTTPResponseStatus, + statusDescription: String? = nil, + headers : HTTPHeaders? = nil, + payload : Payload, + encoder : JSONEncoder = JSONEncoder()) throws + { + var headers = headers ?? HTTPHeaders() + headers.add(name: "Content-Type", value: "application/json") + + self.statusCode = statusCode + self.statusDescription = statusDescription + self.headers = headers + + let buffer = try encoder.encodeAsByteBuffer(payload, allocator: ByteBufferAllocator()) + self.body = buffer.getString(at: 0, length: buffer.readableBytes) ?? "" + self.isBase64Encoded = false + } + + /// Use this method to send any arbitrary byte buffer back to the API Gateway. + /// Sadly Apple currently doesn't seem to be confident enough to advertise + /// their base64 implementation publically. SAD. SO SAD. Therefore no + /// ByteBuffer for you my friend. + public init( + statusCode : HTTPResponseStatus, + statusDescription: String? = nil, + headers : HTTPHeaders? = nil, + buffer : NIO.ByteBuffer) + { + let headers = headers ?? HTTPHeaders() + + self.statusCode = statusCode + self.statusDescription = statusDescription + self.headers = headers + self.body = buffer.withUnsafeReadableBytes { (ptr) -> String in + return String(base64Encoding: ptr) + } + self.isBase64Encoded = true + } + +} + diff --git a/Sources/LambdaRuntime/Events/APIGateway.swift b/Sources/LambdaRuntime/Events/APIGateway.swift index 721f014..4804a65 100644 --- a/Sources/LambdaRuntime/Events/APIGateway.swift +++ b/Sources/LambdaRuntime/Events/APIGateway.swift @@ -2,13 +2,14 @@ import Foundation import NIOFoundationCompat import NIO import NIOHTTP1 +import Base64Kit // https://github.com/aws/aws-lambda-go/blob/master/events/apigw.go public struct APIGateway { - // https://github.com/aws/aws-lambda-go/blob/master/events/apigw.go - public struct Request: Codable { + /// APIGatewayRequest contains data coming from the API Gateway + public struct Request { public struct Context: Codable { @@ -42,12 +43,11 @@ public struct APIGateway { public let resource: String public let path: String - public let httpMethod: String + public let httpMethod: HTTPMethod - public let queryStringParameters: String? + public let queryStringParameters: [String: String]? public let multiValueQueryStringParameters: [String:[String]]? - public let headers: [String: String]? - public let multiValueHeaders: [String: [String]]? + public let headers: HTTPHeaders public let pathParameters: [String:String]? public let stageVariables: [String:String]? @@ -82,11 +82,13 @@ public struct APIGateway { extension APIGateway { public static func handler( - decoder: JSONDecoder = JSONDecoder(), - encoder: JSONEncoder = JSONEncoder(), _ handler: @escaping (APIGateway.Request, Context) -> EventLoopFuture) -> ((NIO.ByteBuffer, Context) -> EventLoopFuture) { + // reuse as much as possible + let encoder = JSONEncoder() + let decoder = JSONDecoder() + return { (inputBytes: NIO.ByteBuffer, ctx: Context) -> EventLoopFuture in let req: APIGateway.Request @@ -111,9 +113,56 @@ extension APIGateway { // MARK: - Request - +extension APIGateway.Request: Decodable { + + enum CodingKeys: String, CodingKey { + + case resource = "resource" + case path = "path" + case httpMethod = "httpMethod" + + case queryStringParameters = "queryStringParameters" + case multiValueQueryStringParameters = "multiValueQueryStringParameters" + case headers = "headers" + case multiValueHeaders = "multiValueHeaders" + case pathParameters = "pathParameters" + case stageVariables = "stageVariables" + + case requestContext = "requestContext" + case body = "body" + case isBase64Encoded = "isBase64Encoded" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let method = try container.decode(String.self, forKey: .httpMethod) + self.httpMethod = HTTPMethod(rawValue: method) + self.path = try container.decode(String.self, forKey: .path) + self.resource = try container.decode(String.self, forKey: .resource) + + self.queryStringParameters = try container.decodeIfPresent( + [String: String].self, + forKey: .queryStringParameters) + self.multiValueQueryStringParameters = try container.decodeIfPresent( + [String: [String]].self, + forKey: .multiValueQueryStringParameters) + + let awsHeaders = try container.decode([String: [String]].self, forKey: .multiValueHeaders) + self.headers = HTTPHeaders(awsHeaders: awsHeaders) + + 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.isBase64Encoded = try container.decode(Bool.self, forKey: .isBase64Encoded) + self.body = try container.decodeIfPresent(String.self, forKey: .body) + } +} + extension APIGateway.Request { - public func payload(decoder: JSONDecoder = JSONDecoder()) throws -> Payload { + public func payload(_ type: Payload.Type, decoder: JSONDecoder = JSONDecoder()) throws -> Payload { let body = self.body ?? "" let capacity = body.lengthOfBytes(using: .utf8) @@ -189,7 +238,6 @@ extension APIGateway.Response { self.isBase64Encoded = false } - #if false /// Use this method to send any arbitrary byte buffer back to the API Gateway. /// Sadly Apple currently doesn't seem to be confident enough to advertise /// their base64 implementation publically. SAD. SO SAD. Therefore no @@ -199,15 +247,14 @@ extension APIGateway.Response { headers : HTTPHeaders? = nil, buffer : NIO.ByteBuffer) { - var headers = headers ?? HTTPHeaders() - headers.add(name: "Content-Type", value: "application/json") + let headers = headers ?? HTTPHeaders() self.statusCode = statusCode self.headers = headers - - self.body = String(base64Encoding: buffer.getBytes(at: 0, length: buffer.readableBytes)) + self.body = buffer.withUnsafeReadableBytes { (ptr) -> String in + return String(base64Encoding: ptr) + } self.isBase64Encoded = true } - #endif } diff --git a/Sources/LambdaRuntime/Events/Cloudwatch.swift b/Sources/LambdaRuntime/Events/Cloudwatch.swift new file mode 100644 index 0000000..b0e93f4 --- /dev/null +++ b/Sources/LambdaRuntime/Events/Cloudwatch.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct Cloudwatch { + + public struct Event { + public let id : String + public let detailType : String + public let source : String + public let accountId : String + public let time : Date + public let region : String + public let resources : [String] + public let detail : Detail + } + + public struct ScheduledEvent: Codable {} + + fileprivate static let dateFormatter: DateFormatter = Cloudwatch.createDateFormatter() + fileprivate static func createDateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } +} + +extension Cloudwatch.Event: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "id" + case detailType = "detail-type" + case source = "source" + case accountId = "account" + case time = "time" + case region = "region" + case resources = "resources" + case detail = "detail" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(String.self, forKey: .id) + self.detailType = try container.decode(String.self, forKey: .detailType) + self.source = try container.decode(String.self, forKey: .source) + self.accountId = try container.decode(String.self, forKey: .accountId) + + let dateString = try container.decode(String.self, forKey: .time) + guard let time = Cloudwatch.dateFormatter.date(from: dateString) else { + let dateFormat = String(describing: Cloudwatch.dateFormatter.dateFormat) + throw DecodingError.dataCorruptedError(forKey: .time, in: container, debugDescription: + "Expected date to be in format `\(dateFormat)`, but `\(dateFormat) does not forfill format`") + } + self.time = time + + self.region = try container.decode(String.self, forKey: .region) + self.resources = try container.decode([String].self, forKey: .resources) + self.detail = try container.decode(Detail.self, forKey: .detail) + } +} diff --git a/Sources/LambdaRuntime/Events/DynamoDB+AttributeValue.swift b/Sources/LambdaRuntime/Events/DynamoDB+AttributeValue.swift new file mode 100644 index 0000000..25912e5 --- /dev/null +++ b/Sources/LambdaRuntime/Events/DynamoDB+AttributeValue.swift @@ -0,0 +1,130 @@ +import Foundation +import NIO +import Base64Kit + +extension DynamoDB { + + public enum AttributeValue { + case boolean(Bool) + case binary(NIO.ByteBuffer) + case binarySet([NIO.ByteBuffer]) + case string(String) + case stringSet([String]) + case null + case number(AWSNumber) + case numberSet([AWSNumber]) + + case list([AttributeValue]) + case map([String: AttributeValue]) + } + +} + +extension DynamoDB.AttributeValue: Decodable { + + enum CodingKeys: String, CodingKey { + case binary = "B" + case bool = "BOOL" + case binarySet = "BS" + case list = "L" + case map = "M" + case number = "N" + case numberSet = "NS" + case null = "NULL" + case string = "S" + case stringSet = "SS" + } + + public init(from decoder: Decoder) throws { + + let container = try decoder.container(keyedBy: CodingKeys.self) + let allocator = ByteBufferAllocator() + + guard container.allKeys.count == 1, let key = container.allKeys.first else { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Expected exactly one key, but got \(container.allKeys.count)") + throw DecodingError.dataCorrupted(context) + } + + switch key { + case .binary: + let encoded = try container.decode(String.self, forKey: .binary) + let bytes = try encoded.base64decoded() + var buffer = allocator.buffer(capacity: bytes.count) + buffer.setBytes(bytes, at: 0) + self = .binary(buffer) + + case .bool: + let value = try container.decode(Bool.self, forKey: .bool) + self = .boolean(value) + + case .binarySet: + let values = try container.decode([String].self, forKey: .binarySet) + let buffers = try values.map { (encoded) -> ByteBuffer in + let bytes = try encoded.base64decoded() + var buffer = allocator.buffer(capacity: bytes.count) + buffer.setBytes(bytes, at: 0) + return buffer + } + self = .binarySet(buffers) + + case .list: + let values = try container.decode([DynamoDB.AttributeValue].self, forKey: .list) + self = .list(values) + + case .map: + let value = try container.decode([String: DynamoDB.AttributeValue].self, forKey: .map) + self = .map(value) + + case .number: + let value = try container.decode(AWSNumber.self, forKey: .number) + self = .number(value) + + case .numberSet: + let values = try container.decode([AWSNumber].self, forKey: .numberSet) + self = .numberSet(values) + + case .null: + self = .null + + case .string: + let value = try container.decode(String.self, forKey: .string) + self = .string(value) + + case .stringSet: + let values = try container.decode([String].self, forKey: .stringSet) + self = .stringSet(values) + } + } +} + +extension DynamoDB.AttributeValue: Equatable { + + static public func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.boolean(let lhs), .boolean(let rhs)): + return lhs == rhs + case (.binary(let lhs), .binary(let rhs)): + return lhs == rhs + case (.binarySet(let lhs), .binarySet(let rhs)): + return lhs == rhs + case (.string(let lhs), .string(let rhs)): + return lhs == rhs + case (.stringSet(let lhs), .stringSet(let rhs)): + return lhs == rhs + case (.null, .null): + return true + case (.number(let lhs), .number(let rhs)): + return lhs == rhs + case (.numberSet(let lhs), .numberSet(let rhs)): + return lhs == rhs + case (.list(let lhs), .list(let rhs)): + return lhs == rhs + case (.map(let lhs), .map(let rhs)): + return lhs == rhs + default: + return false + } + } +} diff --git a/Sources/LambdaRuntime/Events/DynamoDB.swift b/Sources/LambdaRuntime/Events/DynamoDB.swift new file mode 100644 index 0000000..4846d37 --- /dev/null +++ b/Sources/LambdaRuntime/Events/DynamoDB.swift @@ -0,0 +1,175 @@ +import Foundation +import NIO + +/// https://github.com/aws/aws-lambda-go/blob/master/events/dynamodb.go +public struct DynamoDB { + + public struct Event: Decodable { + let records: [EventRecord] + + public enum CodingKeys: String, CodingKey { + case records = "Records" + } + } + + public enum KeyType: String, Codable { + case hash = "HASH" + case range = "RANGE" + } + + public enum OperationType: String, Codable { + case insert = "INSERT" + case modify = "MODIFY" + case remove = "REMOVE" + } + + public enum SharedIteratorType: String, Codable { + case trimHorizon = "TRIM_HORIZON" + case latest = "LATEST" + case atSequenceNumber = "AT_SEQUENCE_NUMBER" + case afterSequenceNumber = "AFTER_SEQUENCE_NUMBER" + } + + public enum StreamStatus: String, Codable { + case enabling = "ENABLING" + case enabled = "ENABLED" + case disabling = "DISABLING" + case disabled = "DISABLED" + } + + public enum StreamViewType: String, Codable { + /// the entire item, as it appeared after it was modified. + case newImage = "NEW_IMAGE" + /// the entire item, as it appeared before it was modified. + case oldImage = "OLD_IMAGE" + /// both the new and the old item images of the item. + case newAndOldImages = "NEW_AND_OLD_IMAGES" + /// only the key attributes of the modified item. + case keysOnly = "KEYS_ONLY" + } + + public struct EventRecord: Decodable { + /// The region in which the GetRecords request was received. + public let awsRegion: String + + /// The main body of the stream record, containing all of the DynamoDB-specific + /// fields. + public let change: StreamRecord + + /// A globally unique identifier for the event that was recorded in this stream + /// record. + public let eventId: String + + /// The type of data modification that was performed on the DynamoDB table: + /// * INSERT - a new item was added to the table. + /// * MODIFY - one or more of an existing item's attributes were modified. + /// * REMOVE - the item was deleted from the table + public let eventName: OperationType + + /// The AWS service from which the stream record originated. For DynamoDB Streams, + /// this is aws:dynamodb. + public let eventSource: String + + /// The version number of the stream record format. This number is updated whenever + /// the structure of Record is modified. + /// + /// Client applications must not assume that eventVersion will remain at a particular + /// value, as this number is subject to change at any time. In general, eventVersion + /// will only increase as the low-level DynamoDB Streams API evolves. + public let eventVersion: String + + /// The event source ARN of DynamoDB + public let eventSourceArn: String + + /// Items that are deleted by the Time to Live process after expiration have + /// the following fields: + /// * Records[].userIdentity.type + /// + /// "Service" + /// * Records[].userIdentity.principalId + /// + /// "dynamodb.amazonaws.com" + public let userIdentity: UserIdentity? + + public enum CodingKeys: String, CodingKey { + case awsRegion = "awsRegion" + case change = "dynamodb" + case eventId = "eventID" + case eventName = "eventName" + case eventSource = "eventSource" + case eventVersion = "eventVersion" + case eventSourceArn = "eventSourceARN" + case userIdentity = "userIdentity" + } + } + + public struct StreamRecord { + /// The approximate date and time when the stream record was created, in UNIX + /// epoch time (http://www.epochconverter.com/) format. + public let approximateCreationDateTime: Date? + + /// The primary key attribute(s) for the DynamoDB item that was modified. + public let keys: [String: AttributeValue] + + /// The item in the DynamoDB table as it appeared after it was modified. + public let newImage: [String: AttributeValue]? + + /// The item in the DynamoDB table as it appeared before it was modified. + public let oldImage: [String: AttributeValue]? + + /// The sequence number of the stream record. + public let sequenceNumber: String + + /// The size of the stream record, in bytes. + public let sizeBytes: Int64 + + /// The type of data from the modified DynamoDB item that was captured in this + /// stream record. + public let streamViewType: StreamViewType + } + + public struct UserIdentity: Codable { + public let type : String + public let principalId: String + } + +} + +extension DynamoDB.StreamRecord: Decodable { + + enum CodingKeys: String, CodingKey { + case approximateCreationDateTime = "ApproximateCreationDateTime" + case keys = "Keys" + case newImage = "NewImage" + case oldImage = "OldImage" + case sequenceNumber = "SequenceNumber" + case sizeBytes = "SizeBytes" + case streamViewType = "StreamViewType" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.keys = try container.decode( + [String: DynamoDB.AttributeValue].self, + forKey: .keys) + + self.newImage = try container.decodeIfPresent( + [String: DynamoDB.AttributeValue].self, + forKey: .newImage) + self.oldImage = try container.decodeIfPresent( + [String: DynamoDB.AttributeValue].self, + forKey: .oldImage) + + self.sequenceNumber = try container.decode(String.self, forKey: .sequenceNumber) + self.sizeBytes = try container.decode(Int64.self, forKey: .sizeBytes) + self.streamViewType = try container.decode(DynamoDB.StreamViewType.self, forKey: .streamViewType) + + if let timestamp = try container.decodeIfPresent(TimeInterval.self, forKey: .approximateCreationDateTime) { + self.approximateCreationDateTime = Date(timeIntervalSince1970: timestamp) + } + else { + self.approximateCreationDateTime = nil + } + } +} diff --git a/Sources/LambdaRuntime/Events/SNS.swift b/Sources/LambdaRuntime/Events/SNS.swift new file mode 100644 index 0000000..1156ad0 --- /dev/null +++ b/Sources/LambdaRuntime/Events/SNS.swift @@ -0,0 +1,172 @@ +import Foundation +import NIO +import Base64Kit + +/// https://github.com/aws/aws-lambda-go/blob/master/events/sns.go +public struct SNS { + + public struct Event: Decodable { + + public struct Record: Decodable { + public let eventVersion: String + public let eventSubscriptionArn: String + public let eventSource: String + public let sns: Message + + public enum CodingKeys: String, CodingKey { + case eventVersion = "EventVersion" + case eventSubscriptionArn = "EventSubscriptionArn" + case eventSource = "EventSource" + case sns = "Sns" + } + } + + public let records: [Record] + + public enum CodingKeys: String, CodingKey { + case records = "Records" + } + } + + public struct Message { + + public enum Attribute { + case string(String) + case binary(ByteBuffer) + } + + public let signature : String + public let messageId : String + public let type : String + public let topicArn : String + public let messageAttributes: [String: Attribute] + public let signatureVersion : String + public let timestamp : Date + public let signingCertURL : String + public let message : String + public let unsubscribeUrl : String + public let subject : String? + + } +} + +extension SNS.Message: Decodable { + + enum CodingKeys: String, CodingKey { + case signature = "Signature" + case messageId = "MessageId" + case type = "Type" + case topicArn = "TopicArn" + case messageAttributes = "MessageAttributes" + case signatureVersion = "SignatureVersion" + case timestamp = "Timestamp" + case signingCertURL = "SigningCertUrl" + case message = "Message" + case unsubscribeUrl = "UnsubscribeUrl" + case subject = "Subject" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.signature = try container.decode(String.self, forKey: .signature) + self.messageId = try container.decode(String.self, forKey: .messageId) + self.type = try container.decode(String.self, forKey: .type) + self.topicArn = try container.decode(String.self, forKey: .topicArn) + self.messageAttributes = try container.decode([String: Attribute].self, forKey: .messageAttributes) + self.signatureVersion = try container.decode(String.self, forKey: .signatureVersion) + + let dateString = try container.decode(String.self, forKey: .timestamp) + guard let timestamp = SNS.Message.dateFormatter.date(from: dateString) else { + let dateFormat = String(describing: SNS.Message.dateFormatter.dateFormat) + throw DecodingError.dataCorruptedError(forKey: .timestamp, in: container, debugDescription: + "Expected date to be in format `\(dateFormat)`, but `\(dateFormat) does not forfill format`") + } + self.timestamp = timestamp + + self.signingCertURL = try container.decode(String.self, forKey: .signingCertURL) + self.message = try container.decode(String.self, forKey: .message) + self.unsubscribeUrl = try container.decode(String.self, forKey: .unsubscribeUrl) + self.subject = try container.decodeIfPresent(String.self, forKey: .subject) + } + + private static let dateFormatter: DateFormatter = SNS.Message.createDateFormatter() + private static func createDateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } + +} + +extension SNS.Message { + + public func payload(decoder: JSONDecoder = JSONDecoder()) throws -> Payload { + let body = self.message + + let capacity = body.lengthOfBytes(using: .utf8) + + // TBD: I am pretty sure, we don't need this buffer copy here. + // Access the strings buffer directly to get to the data. + var buffer = ByteBufferAllocator().buffer(capacity: capacity) + buffer.setString(body, at: 0) + buffer.moveWriterIndex(to: capacity) + + return try decoder.decode(Payload.self, from: buffer) + } +} + +extension SNS.Message.Attribute: Equatable {} + +extension SNS.Message.Attribute: Codable { + + enum CodingKeys: String, CodingKey { + case dataType = "Type" + case dataValue = "Value" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let dataType = try container.decode(String.self, forKey: .dataType) + // https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html#SNSMessageAttributes.DataTypes + switch dataType { + case "String": + let value = try container.decode(String.self, forKey: .dataValue) + self = .string(value) + case "Binary": + let base64encoded = try container.decode(String.self, forKey: .dataValue) + let bytes = try base64encoded.base64decoded() + + var buffer = ByteBufferAllocator().buffer(capacity: bytes.count) + buffer.writeBytes(bytes) + buffer.moveReaderIndex(to: bytes.count) + + self = .binary(buffer) + default: + throw DecodingError.dataCorruptedError(forKey: .dataType, in: container, debugDescription: """ + Unexpected value \"\(dataType)\" for key \(CodingKeys.dataType). + Expected `String` or `Binary`. + """) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .binary(let byteBuffer): + let base64 = byteBuffer.withUnsafeReadableBytes { (pointer) -> String in + return String(base64Encoding: pointer) + } + + try container.encode("Binary", forKey: .dataType) + try container.encode(base64, forKey: .dataValue) + case .string(let string): + try container.encode("String", forKey: .dataType) + try container.encode(string, forKey: .dataValue) + } + } + +} diff --git a/Sources/LambdaRuntime/Events/SQS.swift b/Sources/LambdaRuntime/Events/SQS.swift new file mode 100644 index 0000000..3c6ba4e --- /dev/null +++ b/Sources/LambdaRuntime/Events/SQS.swift @@ -0,0 +1,111 @@ +import Foundation +import NIO + +/// https://github.com/aws/aws-lambda-go/blob/master/events/sqs.go +public struct SQS { + + public struct Event: Codable { + public let records: [Message] + + enum CodingKeys: String, CodingKey { + case records = "Records" + } + } + + public struct Message: Codable { + + /// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_MessageAttributeValue.html + public enum Attribute { + case string(String) + case binary(ByteBuffer) + case number(AWSNumber) + } + + public let messageId : String + public let receiptHandle : String + public let body : String + public let md5OfBody : String + public let md5OfMessageAttributes : String? + public let attributes : [String: String] + public let messageAttributes : [String: Attribute] + public let eventSourceArn : String + public let eventSource : String + public let awsRegion : String + + enum CodingKeys: String, CodingKey { + case messageId + case receiptHandle + case body + case md5OfBody + case md5OfMessageAttributes + case attributes + case messageAttributes + case eventSourceArn = "eventSourceARN" + case eventSource + case awsRegion + } + } +} + +extension SQS.Message.Attribute: Equatable { } + +extension SQS.Message.Attribute: Codable { + + enum CodingKeys: String, CodingKey { + case dataType + case stringValue + case binaryValue + + // BinaryListValue and StringListValue are unimplemented since + // they are not implemented as discussed here: + // https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_MessageAttributeValue.html + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let dataType = try container.decode(String.self, forKey: .dataType) + switch dataType { + case "String": + let value = try container.decode(String.self, forKey: .stringValue) + self = .string(value) + case "Number": + let value = try container.decode(AWSNumber.self, forKey: .stringValue) + self = .number(value) + case "Binary": + let base64encoded = try container.decode(String.self, forKey: .binaryValue) + let bytes = try base64encoded.base64decoded() + + var buffer = ByteBufferAllocator().buffer(capacity: bytes.count) + buffer.writeBytes(bytes) + buffer.moveReaderIndex(to: bytes.count) + + self = .binary(buffer) + default: + throw DecodingError.dataCorruptedError(forKey: .dataType, in: container, debugDescription: """ + Unexpected value \"\(dataType)\" for key \(CodingKeys.dataType). + Expected `String`, `Binary` or `Number`. + """) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .binary(let byteBuffer): + let base64 = byteBuffer.withUnsafeReadableBytes { (pointer) -> String in + return String(base64Encoding: pointer) + } + + try container.encode("Binary", forKey: .dataType) + try container.encode(base64, forKey: .stringValue) + case .string(let string): + try container.encode("String", forKey: .dataType) + try container.encode(string, forKey: .binaryValue) + case .number(let number): + try container.encode("Number", forKey: .dataType) + try container.encode(number, forKey: .binaryValue) + } + } +} diff --git a/Sources/LambdaRuntime/Runtime+Codable.swift b/Sources/LambdaRuntime/Runtime+Codable.swift index 643eec0..92e040b 100644 --- a/Sources/LambdaRuntime/Runtime+Codable.swift +++ b/Sources/LambdaRuntime/Runtime+Codable.swift @@ -6,15 +6,18 @@ extension LambdaRuntime { /// wrapper to use for the register function that wraps the encoding and decoding public static func codable( + decoder: JSONDecoder = JSONDecoder(), _ handler: @escaping (Event, Context) -> EventLoopFuture) -> ((NIO.ByteBuffer, Context) -> EventLoopFuture) { return { (inputBytes: NIO.ByteBuffer, ctx: Context) -> EventLoopFuture in let input: Event do { - input = try JSONDecoder().decode(Event.self, from: inputBytes) + input = try decoder.decode(Event.self, from: inputBytes) } catch { + let payload = inputBytes.getString(at: 0, length: inputBytes.readableBytes) + ctx.logger.error("Could not decode to type `\(String(describing: Event.self))`: \(error), payload: \(String(describing: payload))") return ctx.eventLoop.makeFailedFuture(error) } @@ -26,15 +29,18 @@ extension LambdaRuntime { } public static func codable( + decoder: JSONDecoder = JSONDecoder(), _ handler: @escaping (Event, Context) -> EventLoopFuture) -> ((NIO.ByteBuffer, Context) -> EventLoopFuture) { return { (inputBytes: NIO.ByteBuffer, ctx: Context) -> EventLoopFuture in let input: Event do { - input = try JSONDecoder().decode(Event.self, from: inputBytes) + input = try decoder.decode(Event.self, from: inputBytes) } catch { + let payload = inputBytes.getString(at: 0, length: inputBytes.readableBytes) + ctx.logger.error("Could not decode to type `\(String(describing: Event.self))`: \(error), payload: \(String(describing: payload))") return ctx.eventLoop.makeFailedFuture(error) } diff --git a/Sources/LambdaRuntime/Runtime.swift b/Sources/LambdaRuntime/Runtime.swift index 3c0254b..573d1ae 100644 --- a/Sources/LambdaRuntime/Runtime.swift +++ b/Sources/LambdaRuntime/Runtime.swift @@ -50,7 +50,6 @@ final public class LambdaRuntime { environment: Environment, handler: @escaping Handler) { - self.eventLoopGroup = eventLoopGroup self.runtimeLoop = eventLoopGroup.next() @@ -93,7 +92,8 @@ final public class LambdaRuntime { _ = self.client.getNextInvocation() .hop(to: self.runtimeLoop) .flatMap { (invocation, byteBuffer) -> EventLoopFuture in - + + // TBD: Does it make sense to also set this env variable? setenv("_X_AMZN_TRACE_ID", invocation.traceId, 0) let context = Context( diff --git a/Sources/LambdaRuntime/Utils/HTTPHeaders+Codable.swift b/Sources/LambdaRuntime/Utils/HTTPHeaders+Codable.swift new file mode 100644 index 0000000..1a0168a --- /dev/null +++ b/Sources/LambdaRuntime/Utils/HTTPHeaders+Codable.swift @@ -0,0 +1,15 @@ +import NIOHTTP1 + +extension HTTPHeaders { + + init(awsHeaders: [String: [String]]) { + var nioHeaders: [(String, String)] = [] + awsHeaders.forEach { (key, values) in + values.forEach { (value) in + nioHeaders.append((key, value)) + } + } + + self = HTTPHeaders(nioHeaders) + } +} diff --git a/Tests/LambdaRuntimeTests/AWSNumberTests.swift b/Tests/LambdaRuntimeTests/AWSNumberTests.swift new file mode 100644 index 0000000..a9b5367 --- /dev/null +++ b/Tests/LambdaRuntimeTests/AWSNumberTests.swift @@ -0,0 +1,74 @@ +import Foundation +import XCTest +@testable import LambdaRuntime + +class AWSNumberTests: XCTestCase { + + // MARK: - Int - + + func testInteger() { + let number = AWSNumber(int: 5) + XCTAssertEqual(number.stringValue, "5") + XCTAssertEqual(number.int, 5) + XCTAssertEqual(number.double, 5) + } + + func testIntCoding() { + do { + let number = AWSNumber(int: 3) + struct TestStruct: Codable { + let number: AWSNumber + } + + // Test: Encoding + + let test = TestStruct(number: number) + let data = try JSONEncoder().encode(test) + let json = String(data: data, encoding: .utf8) + XCTAssertEqual(json, "{\"number\":\"3\"}") + + // Test: Decoding + + let decoded = try JSONDecoder().decode(TestStruct.self, from: data) + XCTAssertEqual(decoded.number.int, 3) + } + catch { + XCTFail("unexpected error: \(error)") + } + } + + // MARK: - Double - + + func testDouble() { + let number = AWSNumber(double: 3.14) + XCTAssertEqual(number.stringValue, "3.14") + XCTAssertEqual(number.int, nil) + XCTAssertEqual(number.double, 3.14) + } + + func testDoubleCoding() { + do { + let number = AWSNumber(double: 6.25) + struct TestStruct: Codable { + let number: AWSNumber + } + + // Test: Encoding + + let test = TestStruct(number: number) + let data = try JSONEncoder().encode(test) + let json = String(data: data, encoding: .utf8) + XCTAssertEqual(json, "{\"number\":\"6.25\"}") + + // Test: Decoding + + let decoded = try JSONDecoder().decode(TestStruct.self, from: data) + XCTAssertEqual(decoded.number.int, nil) + XCTAssertEqual(decoded.number.double, 6.25) + } + catch { + XCTFail("unexpected error: \(error)") + } + } + +} diff --git a/Tests/LambdaRuntimeTests/Events/ALBTests.swift b/Tests/LambdaRuntimeTests/Events/ALBTests.swift new file mode 100644 index 0000000..1a0d51b --- /dev/null +++ b/Tests/LambdaRuntimeTests/Events/ALBTests.swift @@ -0,0 +1,134 @@ +import Foundation +import XCTest +@testable import LambdaRuntime + +class ALBTests: XCTestCase { + + static let exampleSingleValueHeadersPayload = """ + { + "requestContext":{ + "elb":{ + "targetGroupArn": "arn:aws:elasticloadbalancing:eu-central-1:079477498937:targetgroup/EinSternDerDeinenNamenTraegt/621febf5a44b2ce5" + } + }, + "httpMethod": "GET", + "path": "/", + "queryStringParameters": {}, + "headers":{ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "accept-encoding": "gzip, deflate", + "accept-language": "en-us", + "connection": "keep-alive", + "host": "event-testl-1wa3wrvmroilb-358275751.eu-central-1.elb.amazonaws.com", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.2 Safari/605.1.15", + "x-amzn-trace-id": "Root=1-5e189143-ad18a2b0a7728cd0dac45e10", + "x-forwarded-for": "90.187.8.137", + "x-forwarded-port": "80", + "x-forwarded-proto": "http" + }, + "body":"", + "isBase64Encoded":false + } + """ + + func testRequestWithSingleValueHeadersPayload() { + let data = ALBTests.exampleSingleValueHeadersPayload.data(using: .utf8)! + do { + let decoder = JSONDecoder() + + let event = try decoder.decode(ALB.TargetGroupRequest.self, from: data) + + XCTAssertEqual(event.httpMethod, .GET) + XCTAssertEqual(event.body, "") + XCTAssertEqual(event.isBase64Encoded, false) + XCTAssertEqual(event.headers.count, 11) + XCTAssertEqual(event.path, "/") + XCTAssertEqual(event.queryStringParameters, [:]) + } + catch { + XCTFail("Unexpected error: \(error)") + } + } + + // MARK: - Response - + + private struct TestStruct: Codable { + let hello: String + } + + private struct SingleValueHeadersResponse: Codable, Equatable { + let statusCode: Int + let body: String + let isBase64Encoded: Bool + let headers: [String: String] + } + + private struct MultiValueHeadersResponse: Codable, Equatable { + let statusCode: Int + let body: String + let isBase64Encoded: Bool + let multiValueHeaders: [String: [String]] + } + + func testJSONResponseWithSingleValueHeaders() throws { + let response = try ALB.TargetGroupResponse(statusCode: .ok, payload: TestStruct(hello: "world")) + let encoder = JSONEncoder() + encoder.userInfo[ALB.TargetGroupResponse.MultiValueHeadersEnabledKey] = false + let data = try encoder.encode(response) + + let expected = SingleValueHeadersResponse( + statusCode: 200, body: "{\"hello\":\"world\"}", + isBase64Encoded: false, + headers: ["Content-Type": "application/json"]) + + let result = try JSONDecoder().decode(SingleValueHeadersResponse.self, from: data) + XCTAssertEqual(result, expected) + } + + func testJSONResponseWithMultiValueHeaders() throws { + let response = try ALB.TargetGroupResponse(statusCode: .ok, payload: TestStruct(hello: "world")) + let encoder = JSONEncoder() + encoder.userInfo[ALB.TargetGroupResponse.MultiValueHeadersEnabledKey] = true + let data = try encoder.encode(response) + + let expected = MultiValueHeadersResponse( + statusCode: 200, body: "{\"hello\":\"world\"}", + isBase64Encoded: false, + multiValueHeaders: ["Content-Type": ["application/json"]]) + + let result = try JSONDecoder().decode(MultiValueHeadersResponse.self, from: data) + XCTAssertEqual(result, expected) + } + + func testEmptyResponseWithMultiValueHeaders() throws { + let response = ALB.TargetGroupResponse(statusCode: .ok) + let encoder = JSONEncoder() + encoder.userInfo[ALB.TargetGroupResponse.MultiValueHeadersEnabledKey] = true + let data = try encoder.encode(response) + + let expected = MultiValueHeadersResponse( + statusCode: 200, body: "", + isBase64Encoded: false, + multiValueHeaders: [:]) + + let result = try JSONDecoder().decode(MultiValueHeadersResponse.self, from: data) + XCTAssertEqual(result, expected) + } + + func testEmptyResponseWithSingleValueHeaders() throws { + let response = ALB.TargetGroupResponse(statusCode: .ok) + let encoder = JSONEncoder() + encoder.userInfo[ALB.TargetGroupResponse.MultiValueHeadersEnabledKey] = false + let data = try encoder.encode(response) + + let expected = SingleValueHeadersResponse( + statusCode: 200, body: "", + isBase64Encoded: false, + headers: [:]) + + let result = try JSONDecoder().decode(SingleValueHeadersResponse.self, from: data) + XCTAssertEqual(result, expected) + } + +} diff --git a/Tests/LambdaRuntimeTests/Events/APIGatewayTests.swift b/Tests/LambdaRuntimeTests/Events/APIGatewayTests.swift index 328e305..96db4c8 100644 --- a/Tests/LambdaRuntimeTests/Events/APIGatewayTests.swift +++ b/Tests/LambdaRuntimeTests/Events/APIGatewayTests.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Fabian Fett on 03.11.19. -// - import Foundation import XCTest import NIO @@ -71,7 +64,7 @@ class APIGatewayTests: XCTestCase { let request = try JSONDecoder().decode(APIGateway.Request.self, from: data) XCTAssertEqual(request.path, "/test") - XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.httpMethod, .GET) } catch { XCTFail("Unexpected error: \(error)") @@ -89,9 +82,9 @@ class APIGatewayTests: XCTestCase { let request = try JSONDecoder().decode(APIGateway.Request.self, from: data) XCTAssertEqual(request.path, "/todos") - XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.httpMethod, .POST) - let todo: Todo = try request.payload() + let todo = try request.payload(Todo.self) XCTAssertEqual(todo.title, "a todo") } catch { diff --git a/Tests/LambdaRuntimeTests/Events/CloudwatchTests.swift b/Tests/LambdaRuntimeTests/Events/CloudwatchTests.swift new file mode 100644 index 0000000..d6fc523 --- /dev/null +++ b/Tests/LambdaRuntimeTests/Events/CloudwatchTests.swift @@ -0,0 +1,40 @@ +import Foundation +import XCTest +@testable import LambdaRuntime + +class CloudwatchTests: XCTestCase { + + static let scheduledEventPayload = """ + { + "id": "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c", + "detail-type": "Scheduled Event", + "source": "aws.events", + "account": "123456789012", + "time": "1970-01-01T00:00:00Z", + "region": "us-east-1", + "resources": [ + "arn:aws:events:us-east-1:123456789012:rule/ExampleRule" + ], + "detail": {} + } + """ + + func testScheduledEventFromJSON() { + let data = CloudwatchTests.scheduledEventPayload.data(using: .utf8)! + do { + let decoder = JSONDecoder() + let event = try decoder.decode(Cloudwatch.Event.self, from: data) + + XCTAssertEqual(event.id , "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c") + XCTAssertEqual(event.detailType, "Scheduled Event") + XCTAssertEqual(event.source , "aws.events") + XCTAssertEqual(event.accountId , "123456789012") + XCTAssertEqual(event.time , Date(timeIntervalSince1970: 0)) + XCTAssertEqual(event.region , "us-east-1") + XCTAssertEqual(event.resources , ["arn:aws:events:us-east-1:123456789012:rule/ExampleRule"]) + } + catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/Tests/LambdaRuntimeTests/Events/DynamoDB+AttributeValueTests.swift b/Tests/LambdaRuntimeTests/Events/DynamoDB+AttributeValueTests.swift new file mode 100644 index 0000000..312ae69 --- /dev/null +++ b/Tests/LambdaRuntimeTests/Events/DynamoDB+AttributeValueTests.swift @@ -0,0 +1,118 @@ +import Foundation +import XCTest +import NIO +@testable import LambdaRuntime + +class DynamoDBAttributeValueTests: XCTestCase { + + func testBoolDecoding() throws { + + let json = "{\"BOOL\": true}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + XCTAssertEqual(value, .boolean(true)) + } + + func testBinaryDecoding() throws { + + let json = "{\"B\": \"YmFzZTY0\"}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + var buffer = ByteBufferAllocator().buffer(capacity: 6) + buffer.setString("base64", at: 0) + XCTAssertEqual(value, .binary(buffer)) + } + + func testBinarySetDecoding() throws { + + let json = "{\"BS\": [\"YmFzZTY0\", \"YWJjMTIz\"]}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + var buffer1 = ByteBufferAllocator().buffer(capacity: 6) + buffer1.setString("base64", at: 0) + + var buffer2 = ByteBufferAllocator().buffer(capacity: 6) + buffer2.setString("abc123", at: 0) + + XCTAssertEqual(value, .binarySet([buffer1, buffer2])) + } + + func testStringDecoding() throws { + + let json = "{\"S\": \"huhu\"}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + XCTAssertEqual(value, .string("huhu")) + } + + func testStringSetDecoding() throws { + + let json = "{\"SS\": [\"huhu\", \"haha\"]}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + XCTAssertEqual(value, .stringSet(["huhu", "haha"])) + } + + func testNullDecoding() throws { + let json = "{\"NULL\": true}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + XCTAssertEqual(value, .null) + } + + func testNumberDecoding() throws { + let json = "{\"N\": \"1.2345\"}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + XCTAssertEqual(value, .number(AWSNumber(double: 1.2345))) + } + + func testNumberSetDecoding() throws { + let json = "{\"NS\": [\"1.2345\", \"-19\"]}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + XCTAssertEqual(value, .numberSet([AWSNumber(double: 1.2345), AWSNumber(int: -19)])) + } + + func testListDecoding() throws { + let json = "{\"L\": [{\"NS\": [\"1.2345\", \"-19\"]}, {\"S\": \"huhu\"}]}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + XCTAssertEqual(value, .list([.numberSet([AWSNumber(double: 1.2345), AWSNumber(int: -19)]), .string("huhu")])) + } + + func testMapDecoding() throws { + let json = "{\"M\": {\"numbers\": {\"NS\": [\"1.2345\", \"-19\"]}, \"string\": {\"S\": \"huhu\"}}}" + let value = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + + XCTAssertEqual(value, .map([ + "numbers": .numberSet([AWSNumber(double: 1.2345), AWSNumber(int: -19)]), + "string": .string("huhu") + ])) + } + + func testEmptyDecoding() throws { + let json = "{\"haha\": 1}" + do { + _ = try JSONDecoder().decode(DynamoDB.AttributeValue.self, from: json.data(using: .utf8)!) + XCTFail("Did not expect to reach this point") + } + catch { + switch error { + case DecodingError.dataCorrupted(let context): + // expected error + XCTAssertEqual(context.codingPath.count, 0) + default: + XCTFail("Unexpected error: \(String(describing: error))") + } + } + + } + + func testEquatable() { + XCTAssertEqual(DynamoDB.AttributeValue.boolean(true), .boolean(true)) + XCTAssertNotEqual(DynamoDB.AttributeValue.boolean(true), .boolean(false)) + XCTAssertNotEqual(DynamoDB.AttributeValue.boolean(true), .string("haha")) + } + +} diff --git a/Tests/LambdaRuntimeTests/Events/DynamoDBTests.swift b/Tests/LambdaRuntimeTests/Events/DynamoDBTests.swift new file mode 100644 index 0000000..c150bfe --- /dev/null +++ b/Tests/LambdaRuntimeTests/Events/DynamoDBTests.swift @@ -0,0 +1,115 @@ +import Foundation +import XCTest +@testable import LambdaRuntime + +class DynamoDBTests: XCTestCase { + + static let streamEventPayload = """ + { + "Records": [ + { + "eventID": "1", + "eventVersion": "1.0", + "dynamodb": { + "ApproximateCreationDateTime": 1.578648338E9, + "Keys": { + "Id": { + "N": "101" + } + }, + "NewImage": { + "Message": { + "S": "New item!" + }, + "Id": { + "N": "101" + } + }, + "StreamViewType": "NEW_AND_OLD_IMAGES", + "SequenceNumber": "111", + "SizeBytes": 26 + }, + "awsRegion": "eu-central-1", + "eventName": "INSERT", + "eventSourceARN": "arn:aws:dynamodb:eu-central-1:account-id:table/ExampleTableWithStream/stream/2015-06-27T00:48:05.899", + "eventSource": "aws:dynamodb" + }, + { + "eventID": "2", + "eventVersion": "1.0", + "dynamodb": { + "ApproximateCreationDateTime": 1.578648338E9, + "OldImage": { + "Message": { + "S": "New item!" + }, + "Id": { + "N": "101" + } + }, + "SequenceNumber": "222", + "Keys": { + "Id": { + "N": "101" + } + }, + "SizeBytes": 59, + "NewImage": { + "Message": { + "S": "This item has changed" + }, + "Id": { + "N": "101" + } + }, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "awsRegion": "eu-central-1", + "eventName": "MODIFY", + "eventSourceARN": "arn:aws:dynamodb:eu-central-1:account-id:table/ExampleTableWithStream/stream/2015-06-27T00:48:05.899", + "eventSource": "aws:dynamodb" + }, + { + "eventID": "3", + "eventVersion": "1.0", + "dynamodb": { + "ApproximateCreationDateTime":1.578648338E9, + "Keys": { + "Id": { + "N": "101" + } + }, + "SizeBytes": 38, + "SequenceNumber": "333", + "OldImage": { + "Message": { + "S": "This item has changed" + }, + "Id": { + "N": "101" + } + }, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "awsRegion": "eu-central-1", + "eventName": "REMOVE", + "eventSourceARN": "arn:aws:dynamodb:eu-central-1:account-id:table/ExampleTableWithStream/stream/2015-06-27T00:48:05.899", + "eventSource": "aws:dynamodb" + } + ] + } + """ + + func testScheduledEventFromJSON() { + let data = DynamoDBTests.streamEventPayload.data(using: .utf8)! + do { + let event = try JSONDecoder().decode(DynamoDB.Event.self, from: data) + + XCTAssertEqual(event.records.count, 3) + + } + catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/Tests/LambdaRuntimeTests/Events/SNSTests.swift b/Tests/LambdaRuntimeTests/Events/SNSTests.swift new file mode 100644 index 0000000..04198d6 --- /dev/null +++ b/Tests/LambdaRuntimeTests/Events/SNSTests.swift @@ -0,0 +1,83 @@ +import Foundation +import XCTest +import NIO +@testable import LambdaRuntime + +class SNSTests: XCTestCase { + + static let eventPayload = """ + { + "Records": [ + { + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:eu-central-1:079477498937:EventSources-SNSTopic-1NHENSE2MQKF5:6fabdb7f-b27e-456d-8e8a-14679db9e40c", + "Sns": { + "Type": "Notification", + "MessageId": "bdb6900e-1ae9-5b4b-b7fc-c681fde222e3", + "TopicArn": "arn:aws:sns:eu-central-1:079477498937:EventSources-SNSTopic-1NHENSE2MQKF5", + "Subject": null, + "Message": "{\\\"hello\\\": \\\"world\\\"}", + "Timestamp": "2020-01-08T14:18:51.203Z", + "SignatureVersion": "1", + "Signature": "LJMF/xmMH7A1gNy2unLA3hmzyf6Be+zS/Yeiiz9tZbu6OG8fwvWZeNOcEZardhSiIStc0TF7h9I+4Qz3omCntaEfayzTGmWN8itGkn2mfn/hMFmPbGM8gEUz3+jp1n6p+iqP3XTx92R0LBIFrU3ylOxSo8+SCOjA015M93wfZzwj0WPtynji9iAvvtf15d8JxPUu1T05BRitpFd5s6ZXDHtVQ4x/mUoLUN8lOVp+rs281/ZdYNUG/V5CwlyUDTOERdryTkBJ/GO1NNPa+6m04ywJFa5d+BC8mDcUcHhhXXjpTEbt8AHBmswK3nudHrVMRO/G4zmssxU2P7ii5+gCfA==", + "SigningCertUrl": "https://sns.eu-central-1.amazonaws.com/SimpleNotificationService-6aad65c2f9911b05cd53efda11f913f9.pem", + "UnsubscribeUrl": "https://sns.eu-central-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-central-1:079477498937:EventSources-SNSTopic-1NHENSE2MQKF5:6fabdb7f-b27e-456d-8e8a-14679db9e40c", + "MessageAttributes": { + "binary":{ + "Type": "Binary", + "Value": "YmFzZTY0" + }, + "string":{ + "Type": "String", + "Value": "abc123" + } + } + } + } + ] + } + """ + + struct TestStruct: Decodable { + let hello: String + } + + func testSimpleEventFromJSON() { + let data = SNSTests.eventPayload.data(using: .utf8)! + do { + let event = try JSONDecoder().decode(SNS.Event.self, from: data) + + guard let record = event.records.first else { + XCTFail("Expected to have one record"); return + } + + XCTAssertEqual(record.eventSource, "aws:sns") + XCTAssertEqual(record.eventVersion, "1.0") + XCTAssertEqual(record.eventSubscriptionArn, "arn:aws:sns:eu-central-1:079477498937:EventSources-SNSTopic-1NHENSE2MQKF5:6fabdb7f-b27e-456d-8e8a-14679db9e40c") + + XCTAssertEqual(record.sns.type, "Notification") + XCTAssertEqual(record.sns.messageId, "bdb6900e-1ae9-5b4b-b7fc-c681fde222e3") + XCTAssertEqual(record.sns.topicArn, "arn:aws:sns:eu-central-1:079477498937:EventSources-SNSTopic-1NHENSE2MQKF5") + XCTAssertEqual(record.sns.message, "{\"hello\": \"world\"}") + XCTAssertEqual(record.sns.timestamp, Date(timeIntervalSince1970: 1578493131.203)) + XCTAssertEqual(record.sns.signatureVersion, "1") + XCTAssertEqual(record.sns.signature, "LJMF/xmMH7A1gNy2unLA3hmzyf6Be+zS/Yeiiz9tZbu6OG8fwvWZeNOcEZardhSiIStc0TF7h9I+4Qz3omCntaEfayzTGmWN8itGkn2mfn/hMFmPbGM8gEUz3+jp1n6p+iqP3XTx92R0LBIFrU3ylOxSo8+SCOjA015M93wfZzwj0WPtynji9iAvvtf15d8JxPUu1T05BRitpFd5s6ZXDHtVQ4x/mUoLUN8lOVp+rs281/ZdYNUG/V5CwlyUDTOERdryTkBJ/GO1NNPa+6m04ywJFa5d+BC8mDcUcHhhXXjpTEbt8AHBmswK3nudHrVMRO/G4zmssxU2P7ii5+gCfA==") + XCTAssertEqual(record.sns.signingCertURL, "https://sns.eu-central-1.amazonaws.com/SimpleNotificationService-6aad65c2f9911b05cd53efda11f913f9.pem") + XCTAssertEqual(record.sns.unsubscribeUrl, "https://sns.eu-central-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-central-1:079477498937:EventSources-SNSTopic-1NHENSE2MQKF5:6fabdb7f-b27e-456d-8e8a-14679db9e40c") + + XCTAssertEqual(record.sns.messageAttributes.count, 2) + + var binaryBuffer = ByteBufferAllocator().buffer(capacity: 6) + binaryBuffer.setString("base64", at: 0) + XCTAssertEqual(record.sns.messageAttributes["binary"], .binary(binaryBuffer)) + XCTAssertEqual(record.sns.messageAttributes["string"], .string("abc123")) + + let payload: TestStruct = try record.sns.payload() + XCTAssertEqual(payload.hello, "world") + } + catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/Tests/LambdaRuntimeTests/Events/SQSTests.swift b/Tests/LambdaRuntimeTests/Events/SQSTests.swift new file mode 100644 index 0000000..86d98ff --- /dev/null +++ b/Tests/LambdaRuntimeTests/Events/SQSTests.swift @@ -0,0 +1,86 @@ +import Foundation +import XCTest +import NIO +@testable import LambdaRuntime + +class SQSTests: XCTestCase { + + static let testPayload = """ + { + "Records": [ + { + "messageId": "19dd0b57-b21e-4ac1-bd88-01bbb068cb78", + "receiptHandle": "MessageReceiptHandle", + "body": "Hello from SQS!", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": { + "number":{ + "stringValue":"123", + "stringListValues":[], + "binaryListValues":[], + "dataType":"Number" + }, + "string":{ + "stringValue":"abc123", + "stringListValues":[], + "binaryListValues":[], + "dataType":"String" + }, + "binary":{ + "dataType": "Binary", + "stringListValues":[], + "binaryListValues":[], + "binaryValue":"YmFzZTY0" + }, + + }, + "md5OfBody": "7b270e59b47ff90a553787216d55d91d", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:MyQueue", + "awsRegion": "us-east-1" + } + ] + } + """ + + + func testSimpleEventFromJSON() { + let data = SQSTests.testPayload.data(using: .utf8)! + do { + let decoder = JSONDecoder() + let event = try decoder.decode(SQS.Event.self, from: data) + + XCTAssertEqual(event.records.count, 1) + + guard let message = event.records.first else { + XCTFail("Expected to have one message in the event") + return + } + + XCTAssertEqual(message.messageId , "19dd0b57-b21e-4ac1-bd88-01bbb068cb78") + XCTAssertEqual(message.receiptHandle , "MessageReceiptHandle") + XCTAssertEqual(message.body , "Hello from SQS!") + XCTAssertEqual(message.attributes.count, 4) + + var binaryBuffer = ByteBufferAllocator().buffer(capacity: 6) + binaryBuffer.setString("base64", at: 0) + XCTAssertEqual(message.messageAttributes, [ + "number": .number(AWSNumber(int: 123)), + "string": .string("abc123"), + "binary": .binary(binaryBuffer) + ]) + XCTAssertEqual(message.md5OfBody , "7b270e59b47ff90a553787216d55d91d") + XCTAssertEqual(message.eventSource , "aws:sqs") + XCTAssertEqual(message.eventSourceArn , "arn:aws:sqs:us-east-1:123456789012:MyQueue") + XCTAssertEqual(message.awsRegion , "us-east-1") + } + catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/Tests/LambdaRuntimeTests/Utils/MockLambdaRuntimeAPI.swift b/Tests/LambdaRuntimeTests/Utils/MockLambdaRuntimeAPI.swift index 9f7f292..1ece753 100644 --- a/Tests/LambdaRuntimeTests/Utils/MockLambdaRuntimeAPI.swift +++ b/Tests/LambdaRuntimeTests/Utils/MockLambdaRuntimeAPI.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Fabian Fett on 11.11.19. -// - import Foundation import NIO @testable import LambdaRuntime