Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lambda XCTest #25

Merged
merged 5 commits into from Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion Package.swift
Expand Up @@ -25,12 +25,15 @@ let package = Package(
.product(name: "ExtrasBase64", package: "swift-extras-base64"),
.product(name: "Hummingbird", package: "hummingbird"),
]),
.target(name: "HummingbirdLambdaTesting", dependencies: [
.byName(name: "HummingbirdLambda"),
]),
.executableTarget(name: "HBLambdaTest", dependencies: [
.byName(name: "HummingbirdLambda"),
]),
.testTarget(name: "HummingbirdLambdaTests", dependencies: [
.byName(name: "HummingbirdLambda"),
.product(name: "HummingbirdXCT", package: "hummingbird"),
.byName(name: "HummingbirdLambdaTesting"),
.product(name: "NIOPosix", package: "swift-nio"),
]),
]
Expand Down
3 changes: 0 additions & 3 deletions Sources/HummingbirdLambda/Lambda.swift
Expand Up @@ -83,7 +83,4 @@ extension HBLambda {
}

public func shutdown() async throws {}

/// default configuration
public var configuration: HBApplicationConfiguration { .init() }
}
6 changes: 3 additions & 3 deletions Sources/HummingbirdLambda/Response+APIGateway.swift
Expand Up @@ -57,9 +57,9 @@ extension HBResponse {
_ = try await self.body.write(collateWriter)
let buffer = collateWriter.buffer
if let contentType = self.headers[.contentType] {
let type = contentType[..<(contentType.firstIndex(of: ";") ?? contentType.endIndex)]
switch type {
case "text/plain", "application/json", "application/x-www-form-urlencoded":
let mediaType = HBMediaType(from: contentType)
switch mediaType {
case .some(.text), .some(.applicationJson), .some(.applicationUrlEncoded):
body = String(buffer: buffer)
default:
break
Expand Down
88 changes: 88 additions & 0 deletions Sources/HummingbirdLambdaTesting/APIGatewayLambda.swift
@@ -0,0 +1,88 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Hummingbird server framework project
//
// Copyright (c) 2021-2024 the Hummingbird authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import AWSLambdaEvents
import Foundation
import HTTPTypes
import HummingbirdCore
import NIOCore

extension APIGatewayRequest: LambdaTestableEvent {
/// Construct APIGateway Event from uri, method, headers and body
public init(uri: String, method: HTTPRequest.Method, headers: HTTPFields, body: ByteBuffer?) throws {
let base64Body = body.map { "\"\(String(base64Encoding: $0.readableBytesView))\"" } ?? "null"
let url = HBURL(uri)
let queryValues: [String: [String]] = url.queryParameters.reduce([:]) { result, value in
var result = result
let key = String(value.key)
var values = result[key] ?? []
values.append(.init(value.value))
result[key] = values
return result
}
let singleQueryValues = queryValues.compactMapValues { $0.count == 1 ? $0.first : nil }
let queryValuesString = try String(decoding: JSONEncoder().encode(singleQueryValues), as: UTF8.self)
let multiQueryValuesString = try String(decoding: JSONEncoder().encode(queryValues), as: UTF8.self)
let headerValues: [String: [String]] = headers.reduce(["host": ["127.0.0.1:8080"]]) { result, value in
var result = result
let key = String(value.name)
var values = result[key] ?? []
values.append(.init(value.value))
result[key] = values
return result
}
let singleHeaderValues = headerValues.compactMapValues { $0.count == 1 ? $0.first : nil }
let headerValuesString = try String(decoding: JSONEncoder().encode(singleHeaderValues), as: UTF8.self)
let multiHeaderValuesString = try String(decoding: JSONEncoder().encode(headerValues), as: UTF8.self)
let eventJson = """
{
"httpMethod": "\(method)",
"body": \(base64Body),
"resource": "\(url.path)",
"requestContext": {
"resourceId": "123456",
"apiId": "1234567890",
"resourcePath": "\(url.path)",
"httpMethod": "\(method)",
"requestId": "\(UUID().uuidString)",
"accountId": "123456789012",
"stage": "Prod",
"identity": {
"apiKey": null,
"userArn": null,
"cognitoAuthenticationType": null,
"caller": null,
"userAgent": "Custom User Agent String",
"user": null,
"cognitoIdentityPoolId": null,
"cognitoAuthenticationProvider": null,
"sourceIp": "127.0.0.1",
"accountId": null
},
"extendedRequestId": null,
"path": "\(uri)"
},
"queryStringParameters": \(queryValuesString),
"multiValueQueryStringParameters": \(multiQueryValuesString),
"headers": \(headerValuesString),
"multiValueHeaders": \(multiHeaderValuesString),
"pathParameters": null,
"stageVariables": null,
"path": "\(url.path)",
"isBase64Encoded": \(body != nil)
}
"""
self = try JSONDecoder().decode(Self.self, from: Data(eventJson.utf8))
}
}
89 changes: 89 additions & 0 deletions Sources/HummingbirdLambdaTesting/APIGatewayV2Lambda.swift
@@ -0,0 +1,89 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Hummingbird server framework project
//
// Copyright (c) 2021-2024 the Hummingbird authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import AWSLambdaEvents
import Foundation
import HTTPTypes
import HummingbirdCore
import NIOCore

extension APIGatewayV2Request: LambdaTestableEvent {
/// Construct APIGatewayV2 Event from uri, method, headers and body
public init(uri: String, method: HTTPRequest.Method, headers: HTTPFields, body: ByteBuffer?) throws {
let base64Body = body.map { "\"\(String(base64Encoding: $0.readableBytesView))\"" } ?? "null"
let url = HBURL(uri)
let queryValues: [String: [String]] = url.queryParameters.reduce([:]) { result, value in
var result = result
let key = String(value.key)
var values = result[key] ?? []
values.append(.init(value.value))
result[key] = values
return result
}
let queryValueStrings = try String(decoding: JSONEncoder().encode(queryValues.mapValues { $0.joined(separator: ",") }), as: UTF8.self)
let headerValues: [String: [String]] = headers.reduce(["host": ["127.0.0.1:8080"]]) { result, value in
var result = result
let key = String(value.name)
var values = result[key] ?? []
values.append(.init(value.value))
result[key] = values
return result
}
let headerValueStrings = try String(decoding: JSONEncoder().encode(headerValues.mapValues { $0.joined(separator: ",") }), as: UTF8.self)
let eventJson = """
{
"routeKey":"\(method) \(url.path)",
"version":"2.0",
"rawPath":"\(url.path)",
"stageVariables":null,
"requestContext":{
"timeEpoch":1587750461466,
"domainPrefix":"hello",
"authorizer":{
"jwt":{
"scopes":[
"hello"
],
"claims":{
"aud":"customers",
"iss":"https://hello.test.com/",
"iat":"1587749276",
"exp":"1587756476"
}
}
},
"accountId":"0123456789",
"stage":"$default",
"domainName":"hello.test.com",
"apiId":"pb5dg6g3rg",
"requestId":"LgLpnibOFiAEPCA=",
"http":{
"path":"\(url.path)",
"userAgent":"Paw/3.1.10 (Macintosh; OS X/10.15.4) GCDHTTPRequest",
"method":"\(method)",
"protocol":"HTTP/1.1",
"sourceIp":"91.64.117.86"
},
"time":"24/Apr/2020:17:47:41 +0000"
},
"body": \(base64Body),
"isBase64Encoded": \(body != nil),
"rawQueryString":"\(url.query ?? "")",
"queryStringParameters":\(queryValueStrings),
"headers":\(headerValueStrings)
}
"""
self = try JSONDecoder().decode(Self.self, from: Data(eventJson.utf8))
}
}
92 changes: 92 additions & 0 deletions Sources/HummingbirdLambdaTesting/HBXCTLambda.swift
@@ -0,0 +1,92 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Hummingbird server framework project
//
// Copyright (c) 2021-2024 the Hummingbird authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import AWSLambdaEvents
@testable import AWSLambdaRuntimeCore
import Foundation
import HTTPTypes
@testable import HummingbirdLambda
import Logging
import NIOCore
import NIOPosix

class HBLambdaTestFramework<Lambda: HBLambda> where Lambda.Event: LambdaTestableEvent {
let context: LambdaContext
var terminator: LambdaTerminator

init(logLevel: Logger.Level) {
var logger = Logger(label: "HBTestLambda")
logger.logLevel = logLevel
self.context = .init(
requestID: UUID().uuidString,
traceID: "abc123",
invokedFunctionARN: "aws:arn:",
deadline: .now() + .seconds(15),
cognitoIdentity: nil,
clientContext: nil,
logger: logger,
eventLoop: MultiThreadedEventLoopGroup.singleton.any(),
allocator: ByteBufferAllocator()
)
self.terminator = .init()
}

var initializationContext: LambdaInitializationContext {
.init(
logger: self.context.logger,
eventLoop: self.context.eventLoop,
allocator: self.context.allocator,
terminator: .init()
)
}

func run<Value>(_ test: @escaping @Sendable (HBLambdaTestClient<Lambda>) async throws -> Value) async throws -> Value {
let handler = try await HBLambdaHandler<Lambda>(context: self.initializationContext)
let value = try await test(HBLambdaTestClient(handler: handler, context: context))
try await self.terminator.terminate(eventLoop: self.context.eventLoop).get()
self.terminator = .init()
return value
}
}

/// Client used to send requests to lambda test framework
public struct HBLambdaTestClient<Lambda: HBLambda> where Lambda.Event: LambdaTestableEvent {
let handler: HBLambdaHandler<Lambda>
let context: LambdaContext

func execute(uri: String, method: HTTPRequest.Method, headers: HTTPFields, body: ByteBuffer?) async throws -> Lambda.Output {
let event = try Lambda.Event(uri: uri, method: method, headers: headers, body: body)
return try await self.handler.handle(event, context: self.context)
}

/// Send request to lambda test framework and call `testCallback`` on the response returned
///
/// - Parameters:
/// - uri: Path of request
/// - method: Request method
/// - headers: Request headers
/// - body: Request body
/// - testCallback: closure to call on response returned by test framework
/// - Returns: Return value of test closure
@discardableResult public func execute<Return>(
uri: String,
method: HTTPRequest.Method,
headers: HTTPFields = [:],
body: ByteBuffer? = nil,
testCallback: @escaping (Lambda.Output) async throws -> Return
) async throws -> Return {
let response = try await execute(uri: uri, method: method, headers: headers, body: body)
return try await testCallback(response)
}
}
51 changes: 51 additions & 0 deletions Sources/HummingbirdLambdaTesting/Lambda+Testing.swift
@@ -0,0 +1,51 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Hummingbird server framework project
//
// Copyright (c) 2021-2024 the Hummingbird authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import HummingbirdLambda
import Logging

extension HBLambda where Event: LambdaTestableEvent {
/// Test `HBLambda`
///
/// The `test` closure uses the provided test client to make calls to the
/// lambda via `execute`. You can verify the contents of the output
/// event returned.
///
/// The example below is using the `.router` framework to test
/// ```swift
/// struct HelloLambda: HBAPIGatewayLambda {
/// init(context: LambdaInitializationContext) {}
///
/// func buildResponder() -> some HBResponder<Context> {
/// let router = HBRouter(context: Context.self)
/// router.get("hello") { request, _ in
/// return "Hello"
/// }
/// return router.buildResponder()
/// }
/// }
/// try await HelloLambda.test { client in
/// try await client.execute(uri: "/hello", method: .get) { response in
/// XCTAssertEqual(response.body, "Hello")
/// }
/// }
/// ```
public static func test<Value>(
logLevel: Logger.Level = .debug,
_ test: @escaping @Sendable (HBLambdaTestClient<Self>) async throws -> Value
) async throws -> Value {
let lambda = HBLambdaTestFramework<Self>(logLevel: logLevel)
return try await lambda.run(test)
}
}
21 changes: 21 additions & 0 deletions Sources/HummingbirdLambdaTesting/LambdaEvent.swift
@@ -0,0 +1,21 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Hummingbird server framework project
//
// Copyright (c) 2021-2024 the Hummingbird authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Foundation
import HTTPTypes
import NIOCore

public protocol LambdaTestableEvent {
init(uri: String, method: HTTPRequest.Method, headers: HTTPFields, body: ByteBuffer?) throws
}