Skip to content

Commit 9c172fe

Browse files
tomerdfabianfett
authored andcommitted
add debug functionality to test with mock server
motivation: allow end to end testing locally changes: * add a Lambda+LocalServer which exposes Lambda.withLocalServer available only in DEBUG mode * local server can recieve POST requests with payloads on a configurable endpoint and and send them to the Lambda * add a "noContent" mode to Lambda runtime to allow polling
1 parent 64ee5a2 commit 9c172fe

File tree

7 files changed

+267
-20
lines changed

7 files changed

+267
-20
lines changed

Package.swift

+4-12
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,12 @@ let package = Package(
4747
.byName(name: "AWSLambdaRuntime"),
4848
.product(name: "NIO", package: "swift-nio"),
4949
]),
50-
.testTarget(name: "AWSLambdaTestingTests", dependencies: [
51-
.byName(name: "AWSLambdaTesting"),
52-
.byName(name: "AWSLambdaRuntime"),
53-
]),
54-
// samples
55-
.target(name: "StringSample", dependencies: [
56-
.byName(name: "AWSLambdaRuntime"),
57-
]),
58-
.target(name: "CodableSample", dependencies: [
59-
.byName(name: "AWSLambdaRuntime"),
60-
]),
61-
// perf tests
50+
.testTarget(name: "AWSLambdaTestingTests", dependencies: ["AWSLambdaTesting"]),
51+
// for perf testing
6252
.target(name: "MockServer", dependencies: [
6353
.product(name: "NIOHTTP1", package: "swift-nio"),
6454
]),
55+
.target(name: "StringSample", dependencies: ["AWSLambdaRuntime"]),
56+
.target(name: "CodableSample", dependencies: ["AWSLambdaRuntime"]),
6557
]
6658
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftAWSLambdaRuntime open source project
4+
//
5+
// Copyright (c) 2020 Apple Inc. and the SwiftAWSLambdaRuntime project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Dispatch
16+
import Logging
17+
import NIO
18+
import NIOConcurrencyHelpers
19+
import NIOHTTP1
20+
21+
// This functionality is designed for local testing hence beind a #if DEBUG flag.
22+
// For example:
23+
//
24+
// try Lambda.withLocalServer {
25+
// Lambda.run { (context: Lambda.Context, payload: String, callback: @escaping (Result<String, Error>) -> Void) in
26+
// callback(.success("Hello, \(payload)!"))
27+
// }
28+
// }
29+
30+
#if DEBUG
31+
extension Lambda {
32+
/// Execute code in the context of a mock Lambda server.
33+
///
34+
/// - parameters:
35+
/// - invocationEndpoint: The endpoint to post payloads to.
36+
/// - body: Code to run within the context of the mock server. Typically this would be a Lambda.run function call.
37+
///
38+
/// - note: This API is designed stricly for local testing and is behind a DEBUG flag
39+
public static func withLocalServer(invocationEndpoint: String? = nil, _ body: @escaping () -> Void) throws {
40+
let server = LocalLambda.Server(invocationEndpoint: invocationEndpoint)
41+
try server.start().wait()
42+
defer { try! server.stop() } // FIXME:
43+
body()
44+
}
45+
}
46+
47+
// MARK: - Local Mock Server
48+
49+
private enum LocalLambda {
50+
struct Server {
51+
private let logger: Logger
52+
private let group: EventLoopGroup
53+
private let host: String
54+
private let port: Int
55+
private let invocationEndpoint: String
56+
57+
public init(invocationEndpoint: String?) {
58+
let configuration = Lambda.Configuration()
59+
var logger = Logger(label: "LocalLambdaServer")
60+
logger.logLevel = configuration.general.logLevel
61+
self.logger = logger
62+
self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
63+
self.host = configuration.runtimeEngine.ip
64+
self.port = configuration.runtimeEngine.port
65+
self.invocationEndpoint = invocationEndpoint ?? "/invoke"
66+
}
67+
68+
func start() -> EventLoopFuture<Void> {
69+
let bootstrap = ServerBootstrap(group: group)
70+
.serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
71+
.childChannelInitializer { channel in
72+
channel.pipeline.configureHTTPServerPipeline(withErrorHandling: true).flatMap { _ in
73+
channel.pipeline.addHandler(HTTPHandler(logger: self.logger, invocationEndpoint: self.invocationEndpoint))
74+
}
75+
}
76+
return bootstrap.bind(host: self.host, port: self.port).flatMap { channel -> EventLoopFuture<Void> in
77+
guard channel.localAddress != nil else {
78+
return channel.eventLoop.makeFailedFuture(ServerError.cantBind)
79+
}
80+
self.logger.info("LocalLambdaServer started and listening on \(self.host):\(self.port), receiving payloads on \(self.invocationEndpoint)")
81+
return channel.eventLoop.makeSucceededFuture(())
82+
}
83+
}
84+
85+
func stop() throws {
86+
try self.group.syncShutdownGracefully()
87+
}
88+
}
89+
90+
final class HTTPHandler: ChannelInboundHandler {
91+
public typealias InboundIn = HTTPServerRequestPart
92+
public typealias OutboundOut = HTTPServerResponsePart
93+
94+
private static let queueLock = Lock()
95+
private static var queue = [String: Pending]()
96+
97+
private var processing = CircularBuffer<(head: HTTPRequestHead, body: ByteBuffer?)>()
98+
99+
private let logger: Logger
100+
private let invocationEndpoint: String
101+
102+
init(logger: Logger, invocationEndpoint: String) {
103+
self.logger = logger
104+
self.invocationEndpoint = invocationEndpoint
105+
}
106+
107+
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
108+
let requestPart = unwrapInboundIn(data)
109+
110+
switch requestPart {
111+
case .head(let head):
112+
self.processing.append((head: head, body: nil))
113+
case .body(var buffer):
114+
var request = self.processing.removeFirst()
115+
if request.body == nil {
116+
request.body = buffer
117+
} else {
118+
request.body!.writeBuffer(&buffer)
119+
}
120+
self.processing.prepend(request)
121+
case .end:
122+
let request = self.processing.removeFirst()
123+
self.processRequest(context: context, request: request)
124+
}
125+
}
126+
127+
func processRequest(context: ChannelHandlerContext, request: (head: HTTPRequestHead, body: ByteBuffer?)) {
128+
if request.head.uri.hasSuffix(self.invocationEndpoint) {
129+
if let work = request.body {
130+
let requestId = "\(DispatchTime.now().uptimeNanoseconds)" // FIXME:
131+
let promise = context.eventLoop.makePromise(of: Response.self)
132+
promise.futureResult.whenComplete { result in
133+
switch result {
134+
case .success(let response):
135+
self.writeResponse(context: context, response: response)
136+
case .failure:
137+
self.writeResponse(context: context, response: .init(status: .internalServerError))
138+
}
139+
}
140+
Self.queueLock.withLock {
141+
Self.queue[requestId] = Pending(requestId: requestId, request: work, responsePromise: promise)
142+
}
143+
}
144+
} else if request.head.uri.hasSuffix("/next") {
145+
switch (Self.queueLock.withLock { Self.queue.popFirst() }) {
146+
case .none:
147+
self.writeResponse(context: context, response: .init(status: .noContent))
148+
case .some(let pending):
149+
var response = Response()
150+
response.body = pending.value.request
151+
// required headers
152+
response.headers = [
153+
(AmazonHeaders.requestID, pending.key),
154+
(AmazonHeaders.invokedFunctionARN, "arn:aws:lambda:us-east-1:\(Int16.random(in: Int16.min ... Int16.max)):function:custom-runtime"),
155+
(AmazonHeaders.traceID, "Root=\(Int16.random(in: Int16.min ... Int16.max));Parent=\(Int16.random(in: Int16.min ... Int16.max));Sampled=1"),
156+
(AmazonHeaders.deadline, "\(DispatchWallTime.distantFuture.millisSinceEpoch)"),
157+
]
158+
Self.queueLock.withLock {
159+
Self.queue[pending.key] = pending.value
160+
}
161+
self.writeResponse(context: context, response: response)
162+
}
163+
164+
} else if request.head.uri.hasSuffix("/response") {
165+
let parts = request.head.uri.split(separator: "/")
166+
guard let requestId = parts.count > 2 ? String(parts[parts.count - 2]) : nil else {
167+
return self.writeResponse(context: context, response: .init(status: .badRequest))
168+
}
169+
switch (Self.queueLock.withLock { Self.queue[requestId] }) {
170+
case .none:
171+
self.writeResponse(context: context, response: .init(status: .badRequest))
172+
case .some(let pending):
173+
pending.responsePromise.succeed(.init(status: .ok, body: request.body))
174+
self.writeResponse(context: context, response: .init(status: .accepted))
175+
Self.queueLock.withLock { Self.queue[requestId] = nil }
176+
}
177+
} else {
178+
self.writeResponse(context: context, response: .init(status: .notFound))
179+
}
180+
}
181+
182+
func writeResponse(context: ChannelHandlerContext, response: Response) {
183+
var headers = HTTPHeaders(response.headers ?? [])
184+
headers.add(name: "content-length", value: "\(response.body?.readableBytes ?? 0)")
185+
let head = HTTPResponseHead(version: HTTPVersion(major: 1, minor: 1), status: response.status, headers: headers)
186+
187+
context.write(wrapOutboundOut(.head(head))).whenFailure { error in
188+
self.logger.error("\(self) write error \(error)")
189+
}
190+
191+
if let buffer = response.body {
192+
context.write(wrapOutboundOut(.body(.byteBuffer(buffer)))).whenFailure { error in
193+
self.logger.error("\(self) write error \(error)")
194+
}
195+
}
196+
197+
context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { result in
198+
if case .failure(let error) = result {
199+
self.logger.error("\(self) write error \(error)")
200+
}
201+
}
202+
}
203+
204+
struct Response {
205+
var status: HTTPResponseStatus = .ok
206+
var headers: [(String, String)]?
207+
var body: ByteBuffer?
208+
}
209+
210+
struct Pending {
211+
let requestId: String
212+
let request: ByteBuffer
213+
let responsePromise: EventLoopPromise<Response>
214+
}
215+
}
216+
217+
enum ServerError: Error {
218+
case notReady
219+
case cantBind
220+
}
221+
}
222+
#endif

Sources/AWSLambdaRuntimeCore/LambdaContext.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import NIO
1919
extension Lambda {
2020
/// Lambda runtime context.
2121
/// The Lambda runtime generates and passes the `Context` to the Lambda handler as an argument.
22-
public final class Context {
22+
public final class Context: CustomDebugStringConvertible {
2323
/// The request ID, which identifies the request that triggered the function invocation.
2424
public let requestId: String
2525

@@ -85,5 +85,9 @@ extension Lambda {
8585
let remaining = deadline - now
8686
return .milliseconds(remaining)
8787
}
88+
89+
public var debugDescription: String {
90+
"\(Self.self)(requestId: \(self.requestId), traceId: \(self.traceId), invokedFunctionArn: \(self.invokedFunctionArn), cognitoIdentity: \(self.cognitoIdentity ?? "nil"), clientContext: \(self.clientContext ?? "nil"), deadline: \(self.deadline))"
91+
}
8892
}
8993
}

Sources/AWSLambdaRuntimeCore/LambdaRunner.swift

+13-4
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@ extension Lambda {
4545

4646
func run(logger: Logger, handler: Handler) -> EventLoopFuture<Void> {
4747
logger.debug("lambda invocation sequence starting")
48-
// 1. request invocation from lambda runtime engine
49-
return self.runtimeClient.getNextInvocation(logger: logger).peekError { error in
50-
logger.error("could not fetch invocation from lambda runtime engine: \(error)")
48+
// 1. request work from lambda runtime engine
49+
return self.runtimeClient.requestWork(logger: logger).peekError { error -> Void in
50+
if case RuntimeError.badStatusCode(.noContent) = error {
51+
return
52+
}
53+
logger.error("could not fetch work from lambda runtime engine: \(error)")
5154
}.flatMap { invocation, payload in
5255
// 2. send invocation to handler
5356
let context = Context(logger: logger, eventLoop: self.eventLoop, invocation: invocation)
@@ -64,7 +67,13 @@ extension Lambda {
6467
self.runtimeClient.reportResults(logger: logger, invocation: invocation, result: result).peekError { error in
6568
logger.error("could not report results to lambda runtime engine: \(error)")
6669
}
67-
}.always { result in
70+
}.flatMapErrorThrowing { error in
71+
if case RuntimeError.badStatusCode(.noContent) = error {
72+
return ()
73+
}
74+
throw error
75+
}
76+
.always { result in
6877
// we are done!
6978
logger.log(level: result.successful ? .debug : .warning, "lambda invocation sequence completed \(result.successful ? "successfully" : "with failure")")
7079
}

Sources/AWSLambdaTesting/Lambda+Testing.swift

+19-1
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,27 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15-
// this is designed to only work for testing
15+
// This functionality is designed to help with Lambda unit testing with XCTest
1616
// #if filter required for release builds which do not support @testable import
1717
// @testable is used to access of internal functions
18+
// For exmaple:
19+
//
20+
// func test() {
21+
// struct MyLambda: EventLoopLambdaHandler {
22+
// typealias In = String
23+
// typealias Out = String
24+
//
25+
// func handle(context: Lambda.Context, payload: String) -> EventLoopFuture<String> {
26+
// return context.eventLoop.makeSucceededFuture("echo" + payload)
27+
// }
28+
// }
29+
//
30+
// let input = UUID().uuidString
31+
// var result: String?
32+
// XCTAssertNoThrow(result = try Lambda.test(MyLambda(), with: input))
33+
// XCTAssertEqual(result, "echo" + input)
34+
// }
35+
1836
#if DEBUG
1937
@testable import AWSLambdaRuntime
2038
@testable import AWSLambdaRuntimeCore

Sources/StringSample/main.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ struct Handler: EventLoopLambdaHandler {
2626
}
2727
}
2828

29-
Lambda.run(Handler())
29+
try Lambda.withLocalServer {
30+
Lambda.run(Handler())
31+
}
3032

3133
// MARK: - this can also be expressed as a closure:
3234

docker/docker-compose.1804.53.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ services:
66
image: swift-aws-lambda:18.04-5.3
77
build:
88
args:
9-
base_image: "swiftlang/swift:nightly-bionic"
9+
base_image: "swiftlang/swift:nightly-5.3-bionic"
1010

1111
test:
1212
image: swift-aws-lambda:18.04-5.3

0 commit comments

Comments
 (0)