diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Package.swift b/Package.swift index 3befa1e..e3d8253 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", .upToNextMajor(from: "2.13.0")), - .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", .upToNextMajor(from: "0.3.0")), + .package(url: "https://github.com/skelpo/swift-aws-lambda-runtime.git", .upToNextMajor(from: "0.4.0")), .package(url: "https://github.com/vapor/vapor.git", .upToNextMajor(from: "4.0.0")), .package(url: "https://github.com/swift-extras/swift-extras-base64", .upToNextMajor(from: "0.4.0")), ], diff --git a/Sources/VaporAWSLambdaRuntime/ALB.swift b/Sources/VaporAWSLambdaRuntime/ALB.swift new file mode 100644 index 0000000..5035b18 --- /dev/null +++ b/Sources/VaporAWSLambdaRuntime/ALB.swift @@ -0,0 +1,158 @@ +// +// File.swift +// +// +// Created by Ralph Küpper on 1/5/21. +// + +import AWSLambdaEvents +import AWSLambdaRuntimeCore +import ExtrasBase64 +import NIO +import NIOHTTP1 +import Vapor + +// MARK: - Handler - + +struct ALBHandler: EventLoopLambdaHandler { + + typealias In = ALB.TargetGroupRequest + typealias Out = ALB.TargetGroupResponse + + private let application: Application + private let responder: Responder + + init(application: Application, responder: Responder) { + self.application = application + self.responder = responder + } + + public func handle(context: Lambda.Context, event: ALB.TargetGroupRequest) + -> EventLoopFuture + { + let vaporRequest: Vapor.Request + do { + vaporRequest = try Vapor.Request(req: event, in: context, for: self.application) + } catch { + return context.eventLoop.makeFailedFuture(error) + } + + return self.responder.respond(to: vaporRequest).flatMap { ALB.TargetGroupResponse.from(response: $0, in: context) } + } +} + +// MARK: - Request - + +extension Vapor.Request { + private static let bufferAllocator = ByteBufferAllocator() + + convenience init(req: ALB.TargetGroupRequest, in ctx: Lambda.Context, for application: Application) throws { + var buffer: NIO.ByteBuffer? + switch (req.body, req.isBase64Encoded) { + case (let .some(string), true): + let bytes = try string.base64decoded() + buffer = Vapor.Request.bufferAllocator.buffer(capacity: bytes.count) + buffer!.writeBytes(bytes) + + case (let .some(string), false): + buffer = Vapor.Request.bufferAllocator.buffer(capacity: string.utf8.count) + buffer!.writeString(string) + + case (.none, _): + break + } + + var nioHeaders = NIOHTTP1.HTTPHeaders() + req.headers?.forEach { key, value in + nioHeaders.add(name: key, value: value) + } + + /*if let cookies = req., cookies.count > 0 { + nioHeaders.add(name: "Cookie", value: cookies.joined(separator: "; ")) + }*/ + + var url: String = req.path + if req.queryStringParameters.count > 0 { + url += "?" + for key in req.queryStringParameters.keys { + // It leaves an ampersand (&) at the end, but who cares? + url += key + "=" + (req.queryStringParameters[key] ?? "") + "&" + } + } + + ctx.logger.debug("The constructed URL is: \(url)") + + self.init( + application: application, + method: NIOHTTP1.HTTPMethod(rawValue: req.httpMethod.rawValue), + url: Vapor.URI(path: url), + version: HTTPVersion(major: 1, minor: 1), + headers: nioHeaders, + collectedBody: buffer, + remoteAddress: nil, + logger: ctx.logger, + on: ctx.eventLoop + ) + + storage[ALB.TargetGroupRequest] = req + } +} + +extension ALB.TargetGroupRequest: Vapor.StorageKey { + public typealias Value = ALB.TargetGroupRequest +} + +// MARK: - Response - + +extension ALB.TargetGroupResponse { + static func from(response: Vapor.Response, in context: Lambda.Context) -> EventLoopFuture { + // Create the headers + var headers = [String: String]() + response.headers.forEach { name, value in + if let current = headers[name] { + headers[name] = "\(current),\(value)" + } else { + headers[name] = value + } + } + + // Can we access the body right away? + if let string = response.body.string { + return context.eventLoop.makeSucceededFuture(.init( + statusCode: AWSLambdaEvents.HTTPResponseStatus(code: response.status.code), + headers: headers, + body: string, + isBase64Encoded: false + )) + } else if let bytes = response.body.data { + return context.eventLoop.makeSucceededFuture(.init( + statusCode: AWSLambdaEvents.HTTPResponseStatus(code: response.status.code), + headers: headers, + body: String(base64Encoding: bytes), + isBase64Encoded: true + )) + } else { + // See if it is a stream and try to gather the data + return response.body.collect(on: context.eventLoop).map { (buffer) -> ALB.TargetGroupResponse in + // Was there any content + guard + var buffer = buffer, + let bytes = buffer.readBytes(length: buffer.readableBytes) + else { + return ALB.TargetGroupResponse( + statusCode: AWSLambdaEvents.HTTPResponseStatus(code: response.status.code), + headers: headers + ) + } + + // Done + return ALB.TargetGroupResponse( + statusCode: AWSLambdaEvents.HTTPResponseStatus(code: response.status.code), + headers: headers, + body: String(base64Encoding: bytes), + isBase64Encoded: true + ) + } + } + } +} diff --git a/Sources/VaporAWSLambdaRuntime/LambdaServer.swift b/Sources/VaporAWSLambdaRuntime/LambdaServer.swift index de1fbf1..71c5bb8 100644 --- a/Sources/VaporAWSLambdaRuntime/LambdaServer.swift +++ b/Sources/VaporAWSLambdaRuntime/LambdaServer.swift @@ -66,8 +66,8 @@ public extension Application.Lambda { } } - struct ConfigurationKey: StorageKey { - typealias Value = LambdaServer.Configuration + public struct ConfigurationKey: StorageKey { + public typealias Value = LambdaServer.Configuration } } } @@ -79,13 +79,14 @@ public class LambdaServer: Server { public enum RequestSource { case apiGateway case apiGatewayV2 -// case applicationLoadBalancer // not in this release + case applicationLoadBalancer + case sqs } var requestSource: RequestSource var logger: Logger - init(apiService: RequestSource = .apiGatewayV2, logger: Logger) { + public init(apiService: RequestSource = .apiGatewayV2, logger: Logger) { self.requestSource = apiService self.logger = logger } @@ -115,6 +116,10 @@ public class LambdaServer: Server { handler = APIGatewayHandler(application: application, responder: responder) case .apiGatewayV2: handler = APIGatewayV2Handler(application: application, responder: responder) + case .applicationLoadBalancer: + handler = ALBHandler(application: application, responder: responder) + case .sqs: + handler = SQSHandler(application: application, responder: responder) } self.lambdaLifecycle = Lambda.Lifecycle( diff --git a/Sources/VaporAWSLambdaRuntime/SQS.swift b/Sources/VaporAWSLambdaRuntime/SQS.swift new file mode 100644 index 0000000..167cf88 --- /dev/null +++ b/Sources/VaporAWSLambdaRuntime/SQS.swift @@ -0,0 +1,152 @@ +// +// File.swift +// +// +// Created by Ralph Küpper on 10/23/21. +// + + +import AWSLambdaEvents +import AWSLambdaRuntimeCore +import ExtrasBase64 +import NIO +import NIOHTTP1 +import Vapor + +// MARK: - Handler - + +struct SQSHandler: EventLoopLambdaHandler { + + typealias In = SQS.Event + typealias Out = SQSResponse + + private let application: Application + private let responder: Responder + + init(application: Application, responder: Responder) { + self.application = application + self.responder = responder + } + + public func handle(context: Lambda.Context, event: SQS.Event) + -> EventLoopFuture + { + let vaporRequest: Vapor.Request + do { + vaporRequest = try Vapor.Request(req: event, in: context, for: self.application) + } catch { + return context.eventLoop.makeFailedFuture(error) + } + + return self.responder.respond(to: vaporRequest).flatMap { SQSResponse.from(response: $0, in: context) } + } +} + +// MARK: - Request - + +extension Vapor.Request { + private static let bufferAllocator = ByteBufferAllocator() + + convenience init(req: SQS.Event, in ctx: Lambda.Context, for application: Application) throws { + let event = req.records.first! + print("incoming events: ", req.records.count) + /*var buffer: NIO.ByteBuffer? + switch (req.body, req.isBase64Encoded) { + case (let .some(string), true): + let bytes = try string.base64decoded() + buffer = Vapor.Request.bufferAllocator.buffer(capacity: bytes.count) + buffer!.writeBytes(bytes) + + case (let .some(string), false): + buffer = Vapor.Request.bufferAllocator.buffer(capacity: string.utf8.count) + buffer!.writeString(string) + + case (.none, _): + break + } + + var nioHeaders = NIOHTTP1.HTTPHeaders() + req.headers?.forEach { key, value in + nioHeaders.add(name: key, value: value) + } + + /*if let cookies = req., cookies.count > 0 { + nioHeaders.add(name: "Cookie", value: cookies.joined(separator: "; ")) + }*/ + + var url: String = req.path + if req.queryStringParameters.count > 0 { + url += "?" + for key in req.queryStringParameters.keys { + // It leaves an ampersand (&) at the end, but who cares? + url += key + "=" + (req.queryStringParameters[key] ?? "") + "&" + } + }*/ + var buffer: NIO.ByteBuffer? + buffer = Vapor.Request.bufferAllocator.buffer(capacity: event.body.utf8.count) + buffer!.writeString(event.body) + + let url = "/sqs" + + ctx.logger.debug("The constructed URL is: \(url)") + + self.init( + application: application, + method: NIOHTTP1.HTTPMethod.POST, + url: Vapor.URI(path: url), + version: HTTPVersion(major: 1, minor: 1), + headers: [:], + collectedBody: buffer, + remoteAddress: nil, + logger: ctx.logger, + on: ctx.eventLoop + ) + + storage[SQS.Event] = req + } +} + +extension SQS.Event: Vapor.StorageKey { + public typealias Value = SQS.Event +} + +// MARK: - Response - + +struct SQSResponse: Codable { + public var statusCode: HTTPResponseStatus + public var statusDescription: String? + public var headers: HTTPHeaders? + public var multiValueHeaders: HTTPMultiValueHeaders? + public var body: String + public var isBase64Encoded: Bool + + public init( + statusCode: HTTPResponseStatus, + statusDescription: String? = nil, + headers: HTTPHeaders? = nil, + multiValueHeaders: HTTPMultiValueHeaders? = nil, + body: String = "", + isBase64Encoded: Bool = false + ) { + self.statusCode = statusCode + self.statusDescription = statusDescription + self.headers = headers + self.multiValueHeaders = multiValueHeaders + self.body = body + self.isBase64Encoded = isBase64Encoded + } + + static func from(response: Vapor.Response, in context: Lambda.Context) -> EventLoopFuture { + // Create the headers + var headers: HTTPHeaders = [:] + + // Can we access the body right away? + let string = response.body.string ?? "" + return context.eventLoop.makeSucceededFuture(.init( + statusCode: HTTPResponseStatus.ok, + headers: headers, + body: string, + isBase64Encoded: false + )) + } +} diff --git a/Tests/VaporAWSLambdaRuntimeTests/ALB.swift b/Tests/VaporAWSLambdaRuntimeTests/ALB.swift new file mode 100644 index 0000000..403984d --- /dev/null +++ b/Tests/VaporAWSLambdaRuntimeTests/ALB.swift @@ -0,0 +1,80 @@ +import AWSLambdaEvents +@testable import AWSLambdaRuntimeCore +import Logging +import NIO +import Vapor +@testable import VaporAWSLambdaRuntime +import XCTest + +final class ALBTests: XCTestCase { + func testALBRequest() throws { + let requestdata = """ + { + "requestContext": { + "elb": { + "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a" + } + }, + "httpMethod": "GET", + "path": "/lambda", + "queryStringParameters": { + "query": "1234ABCD" + }, + "headers": { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", + "accept-encoding": "gzip", + "accept-language": "en-US,en;q=0.9", + "connection": "keep-alive", + "host": "lambda-alb-123578498.us-east-2.elb.amazonaws.com", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36", + "x-amzn-trace-id": "Root=1-5c536348-3d683b8b04734faae651f476", + "x-forwarded-for": "72.12.164.125", + "x-forwarded-port": "80", + "x-forwarded-proto": "http", + "x-imforwards": "20" + }, + "body": "", + "isBase64Encoded": false + } + """ + let decoder = JSONDecoder() + let request = try decoder.decode(ALB.TargetGroupRequest.self, from: requestdata.data(using: .utf8)!) + print("F: ", request) + } + + func testCreateALBResponse() { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) } + let eventLoop = eventLoopGroup.next() + let allocator = ByteBufferAllocator() + let logger = Logger(label: "test") + + let body = #"{"hello": "world"}"# + let vaporResponse = Vapor.Response( + status: .ok, + headers: HTTPHeaders([ + ("Content-Type", "application/json"), + ]), + body: .init(string: body) + ) + + let context = Lambda.Context( + requestID: "abc123", + traceID: AmazonHeaders.generateXRayTraceID(), + invokedFunctionARN: "function-arn", + deadline: .now() + .seconds(3), + logger: logger, + eventLoop: eventLoop, + allocator: allocator + ) + + var response: ALB.TargetGroupResponse? + XCTAssertNoThrow(response = try ALB.TargetGroupResponse.from(response: vaporResponse, in: context).wait()) + + XCTAssertEqual(response?.body, body) + XCTAssertEqual(response?.headers?.count, 2) + XCTAssertEqual(response?.headers?["Content-Type"], "application/json") + XCTAssertEqual(response?.headers?["content-length"], String(body.count)) + } +} diff --git a/Tests/VaporAWSLambdaRuntimeTests/SQSTests.swift b/Tests/VaporAWSLambdaRuntimeTests/SQSTests.swift new file mode 100644 index 0000000..6951d25 --- /dev/null +++ b/Tests/VaporAWSLambdaRuntimeTests/SQSTests.swift @@ -0,0 +1,79 @@ +// +// File.swift +// +// +// Created by Ralph Küpper on 10/23/21. +// + +import AWSLambdaEvents +@testable import AWSLambdaRuntimeCore +import Logging +import NIO +import Vapor +@testable import VaporAWSLambdaRuntime +import XCTest + +final class SQSTests: XCTestCase { + func testSQSRequest() throws { + let requestdata = """ + { + "Records": [ + { + "messageId": "19dd0b57-b21e-4ac1-bd88-01bbb068cb78", + "receiptHandle": "MessageReceiptHandle", + "body": "Hello from SQS!", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "{{{md5_of_body}}}", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:MyQueue", + "awsRegion": "us-east-1" + } + ] + } + """ + let decoder = JSONDecoder() + let request = try decoder.decode(SQS.Event.self, from: requestdata.data(using: .utf8)!) + print("F: ", request) + } + + func testCreateALBResponse() { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) } + let eventLoop = eventLoopGroup.next() + let allocator = ByteBufferAllocator() + let logger = Logger(label: "test") + + let body = #"{"hello": "world"}"# + let vaporResponse = Vapor.Response( + status: .ok, + headers: HTTPHeaders([ + ("Content-Type", "application/json"), + ]), + body: .init(string: body) + ) + + let context = Lambda.Context( + requestID: "abc123", + traceID: AmazonHeaders.generateXRayTraceID(), + invokedFunctionARN: "function-arn", + deadline: .now() + .seconds(3), + logger: logger, + eventLoop: eventLoop, + allocator: allocator + ) + + var response: ALB.TargetGroupResponse? + XCTAssertNoThrow(response = try ALB.TargetGroupResponse.from(response: vaporResponse, in: context).wait()) + + XCTAssertEqual(response?.body, body) + XCTAssertEqual(response?.headers?.count, 2) + XCTAssertEqual(response?.headers?["Content-Type"], "application/json") + XCTAssertEqual(response?.headers?["content-length"], String(body.count)) + } +} diff --git a/examples/Hello/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/examples/Hello/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/examples/Hello/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/Hello/.swiftpm/xcode/xcshareddata/xcschemes/Hello.xcscheme b/examples/Hello/.swiftpm/xcode/xcshareddata/xcschemes/Hello.xcscheme new file mode 100644 index 0000000..1e67dfd --- /dev/null +++ b/examples/Hello/.swiftpm/xcode/xcshareddata/xcschemes/Hello.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Hello/Package.resolved b/examples/Hello/Package.resolved index f69666b..cfc54ae 100644 --- a/examples/Hello/Package.resolved +++ b/examples/Hello/Package.resolved @@ -39,11 +39,11 @@ }, { "package": "swift-aws-lambda-runtime", - "repositoryURL": "https://github.com/swift-server/swift-aws-lambda-runtime.git", + "repositoryURL": "https://github.com/skelpo/swift-aws-lambda-runtime.git", "state": { "branch": null, - "revision": "2bac89639fffd7b1197ab597473a4d10c459a230", - "version": "0.2.0" + "revision": "eb8924dde415c8d9daca242556c23b84b2ef3a5d", + "version": "0.4.0" } }, { @@ -56,21 +56,21 @@ } }, { - "package": "swift-base64-kit", - "repositoryURL": "https://github.com/fabianfett/swift-base64-kit", + "package": "swift-crypto", + "repositoryURL": "https://github.com/apple/swift-crypto.git", "state": { "branch": null, - "revision": "3ffa48a7047fc9ac6581cd53ab1df29466d8f13b", - "version": "0.2.0" + "revision": "9b9d1868601a199334da5d14f4ab2d37d4f8d0c5", + "version": "1.0.2" } }, { - "package": "swift-crypto", - "repositoryURL": "https://github.com/apple/swift-crypto.git", + "package": "swift-extras-base64", + "repositoryURL": "https://github.com/swift-extras/swift-extras-base64", "state": { "branch": null, - "revision": "9b9d1868601a199334da5d14f4ab2d37d4f8d0c5", - "version": "1.0.2" + "revision": "bf6706e1811e746cb204deaa921d8c7b4d0509e2", + "version": "0.4.0" } }, { diff --git a/examples/Hello/Sources/Hello/main.swift b/examples/Hello/Sources/Hello/main.swift index e2e6f79..d6c6316 100644 --- a/examples/Hello/Sources/Hello/main.swift +++ b/examples/Hello/Sources/Hello/main.swift @@ -19,6 +19,9 @@ app.post("hello") { req -> Hello in let name = try req.content.decode(Name.self) return Hello(hello: name.name) } - +app.storage[Application.Lambda.Server.ConfigurationKey.self] = .init(apiService: .applicationLoadBalancer, + logger: app.logger) app.servers.use(.lambda) try app.run() + + diff --git a/examples/Hello/makefile b/examples/Hello/makefile index f28bec8..8335e2a 100644 --- a/examples/Hello/makefile +++ b/examples/Hello/makefile @@ -11,7 +11,7 @@ build_lambda: $(SWIFT_DOCKER_IMAGE) \ swift build --product Hello -c release -package_lambda: build_lambda +package_lambda: docker run \ --rm \ --volume "$(shell pwd)/../..:/src" \