Skip to content

Commit

Permalink
2.x.x - Add AsyncHTTPClient testing setup (#317)
Browse files Browse the repository at this point in the history
* Add AHC testing client

Also merge HummingbirdCoreXCT with HummingbirdXCT

* Update Package.swift

* Remove unused functions

* Simplify HBApplication.test switch statement

* Fix HBXCTLive throwing errors
  • Loading branch information
adam-fowler authored Dec 21, 2023
1 parent ec6049d commit 096df59
Show file tree
Hide file tree
Showing 15 changed files with 593 additions and 123 deletions.
10 changes: 2 additions & 8 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,10 @@ let package = Package(
]),
.target(name: "HummingbirdXCT", dependencies: [
.byName(name: "Hummingbird"),
.byName(name: "HummingbirdCoreXCT"),
.product(name: "HTTPTypes", package: "swift-http-types"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
]),
.target(name: "HummingbirdCoreXCT", dependencies: [
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "HTTPTypes", package: "swift-http-types"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOConcurrencyHelpers", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "NIOHTTPTypes", package: "swift-nio-extras"),
.product(name: "NIOHTTPTypesHTTP1", package: "swift-nio-extras"),
.product(name: "NIOPosix", package: "swift-nio"),
Expand Down Expand Up @@ -124,7 +118,7 @@ let package = Package(
.byName(name: "HummingbirdCore"),
.byName(name: "HummingbirdHTTP2"),
.byName(name: "HummingbirdTLS"),
.byName(name: "HummingbirdCoreXCT"),
.byName(name: "HummingbirdXCT"),
.product(name: "AsyncHTTPClient", package: "async-http-client"),
],
resources: [.process("Certificates")]
Expand Down
2 changes: 1 addition & 1 deletion Sources/Hummingbird/Codable/CodableProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@ public struct NullEncoder: HBResponseEncoder {
public struct NullDecoder: HBRequestDecoder {
public init() {}
public func decode<T: Decodable>(_ type: T.Type, from request: HBRequest, context: some HBBaseRequestContext) throws -> T {
preconditionFailure("HBApplication.decoder has not been set")
preconditionFailure("Request context decoder has not been set")
}
}
51 changes: 24 additions & 27 deletions Sources/HummingbirdXCT/Application+XCT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,27 @@ import Hummingbird
import HummingbirdCore
import NIOCore

/// Type of test framework
public struct XCTLiveTestingSetup {
/// Sets up a live server and execute tests using a HTTP client.
public static let live = XCTLiveTestingSetup()
public enum XCTScheme: String {
case http
case https
}

public struct XCTRouterTestingSetup {
/// Type of test framework
public struct XCTTestingSetup {
enum Internal {
case router
case live
case ahc(XCTScheme)
}

let value: Internal

/// Test writing requests directly to router.
public static let router = XCTRouterTestingSetup()
public static var router: XCTTestingSetup { .init(value: .router) }
/// Sets up a live server and execute tests using a HTTP client.
public static var live: XCTTestingSetup { .init(value: .live) }
/// Sets up a live server and execute tests using a HTTP client.
public static func ahc(_ scheme: XCTScheme) -> XCTTestingSetup { .init(value: .ahc(scheme)) }
}

/// Extends `HBApplication` to support testing of applications
Expand Down Expand Up @@ -58,29 +70,14 @@ extension HBApplicationProtocol where Responder.Context: HBRequestContext {
/// - testing: indicates which type of testing framework we want
/// - configuration: configuration of application
public func test<Value>(
_: XCTLiveTestingSetup,
_ test: @escaping @Sendable (any HBXCTClientProtocol) async throws -> Value
) async throws -> Value {
let app: any HBXCTApplication
app = HBXCTLive(app: self)
return try await app.run(test)
}
}

extension HBApplicationProtocol where Responder.Context: HBBaseRequestContext {
// MARK: Initialization

/// Creates a version of `HBApplication` that can be used for testing code
///
/// - Parameters:
/// - testing: indicates which type of testing framework we want
/// - configuration: configuration of application
public func test<Value>(
_: XCTRouterTestingSetup,
_ testingSetup: XCTTestingSetup,
_ test: @escaping @Sendable (any HBXCTClientProtocol) async throws -> Value
) async throws -> Value {
let app: any HBXCTApplication
app = try await HBXCTRouter(app: self)
let app: any HBXCTApplication = switch testingSetup.value {
case .router: try await HBXCTRouter(app: self)
case .live: HBXCTLive(app: self)
case .ahc(let scheme): HBXCTAsyncHTTPClient(app: self, scheme: scheme)
}
return try await app.run(test)
}
}
247 changes: 247 additions & 0 deletions Sources/HummingbirdXCT/HBXCTAsyncHTTPClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Hummingbird server framework project
//
// Copyright (c) 2021-2023 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 AsyncHTTPClient
import HTTPTypes
import Hummingbird
import HummingbirdCore
import Logging
import NIOCore
import NIOHTTP1
import NIOPosix
import NIOSSL
import ServiceLifecycle
import XCTest

/// Test using a live server
final class HBXCTAsyncHTTPClient<App: HBApplicationProtocol>: HBXCTApplication {
struct Client: HBXCTClientProtocol {
let client: HTTPClient
let urlPrefix: String
let timeout: TimeAmount

/// Send request and call test callback on the response returned
func execute(
uri: String,
method: HTTPRequest.Method,
headers: HTTPFields = [:],
body: ByteBuffer? = nil
) async throws -> HBXCTResponse {
let url = "\(self.urlPrefix)\(uri.first == "/" ? "" : "/")\(uri)"
var request = HTTPClientRequest(url: url)
request.method = .init(method)
request.headers = .init(headers)
request.body = body.map { .bytes($0) }
let response = try await client.execute(request, deadline: .now() + self.timeout)
let responseHead = HTTPResponseHead(version: response.version, status: response.status, headers: response.headers)
return try await .init(head: .init(responseHead), body: response.body.collect(upTo: .max))
}
}

init(app: App, scheme: XCTScheme) {
self.timeout = .seconds(15)
self.application = TestApplication(base: app)
self.scheme = scheme
}

/// Start tests
func run<Value>(_ test: @escaping @Sendable (HBXCTClientProtocol) async throws -> Value) async throws -> Value {
try await withThrowingTaskGroup(of: Void.self) { group in
let serviceGroup = ServiceGroup(
configuration: .init(
services: [self.application],
gracefulShutdownSignals: [.sigterm, .sigint],
logger: self.application.logger
)
)
group.addTask {
try await serviceGroup.run()
}
let port = await self.application.portPromise.wait()
var tlsConfiguration = TLSConfiguration.makeClientConfiguration()
tlsConfiguration.certificateVerification = .none
let httpClient = HTTPClient(
eventLoopGroupProvider: .singleton,
configuration: .init(tlsConfiguration: tlsConfiguration)
)
let client = Client(client: httpClient, urlPrefix: "\(self.scheme)://localhost:\(port)", timeout: self.timeout)
do {
let value = try await test(client)
await serviceGroup.triggerGracefulShutdown()
try await httpClient.shutdown()
return value
} catch {
await serviceGroup.triggerGracefulShutdown()
try await httpClient.shutdown()
throw error
}
}
}

let application: TestApplication<App>
let scheme: XCTScheme
let timeout: TimeAmount
}

//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

private enum HTTP1TypeConversionError: Error {
case invalidMethod
case missingPath
case invalidStatusCode
}

extension HTTPMethod {
init(_ newMethod: HTTPRequest.Method) {
switch newMethod {
case .get: self = .GET
case .head: self = .HEAD
case .post: self = .POST
case .put: self = .PUT
case .delete: self = .DELETE
case .connect: self = .CONNECT
case .options: self = .OPTIONS
case .trace: self = .TRACE
case .patch: self = .PATCH
default:
let rawValue = newMethod.rawValue
switch rawValue {
case "ACL": self = .ACL
case "COPY": self = .COPY
case "LOCK": self = .LOCK
case "MOVE": self = .MOVE
case "BIND": self = .BIND
case "LINK": self = .LINK
case "MKCOL": self = .MKCOL
case "MERGE": self = .MERGE
case "PURGE": self = .PURGE
case "NOTIFY": self = .NOTIFY
case "SEARCH": self = .SEARCH
case "UNLOCK": self = .UNLOCK
case "REBIND": self = .REBIND
case "UNBIND": self = .UNBIND
case "REPORT": self = .REPORT
case "UNLINK": self = .UNLINK
case "MSEARCH": self = .MSEARCH
case "PROPFIND": self = .PROPFIND
case "CHECKOUT": self = .CHECKOUT
case "PROPPATCH": self = .PROPPATCH
case "SUBSCRIBE": self = .SUBSCRIBE
case "MKCALENDAR": self = .MKCALENDAR
case "MKACTIVITY": self = .MKACTIVITY
case "UNSUBSCRIBE": self = .UNSUBSCRIBE
case "SOURCE": self = .SOURCE
default: self = .RAW(value: rawValue)
}
}
}
}

extension HTTPRequest.Method {
init(_ oldMethod: HTTPMethod) throws {
switch oldMethod {
case .GET: self = .get
case .PUT: self = .put
case .ACL: self = .init("ACL")!
case .HEAD: self = .head
case .POST: self = .post
case .COPY: self = .init("COPY")!
case .LOCK: self = .init("LOCK")!
case .MOVE: self = .init("MOVE")!
case .BIND: self = .init("BIND")!
case .LINK: self = .init("LINK")!
case .PATCH: self = .patch
case .TRACE: self = .trace
case .MKCOL: self = .init("MKCOL")!
case .MERGE: self = .init("MERGE")!
case .PURGE: self = .init("PURGE")!
case .NOTIFY: self = .init("NOTIFY")!
case .SEARCH: self = .init("SEARCH")!
case .UNLOCK: self = .init("UNLOCK")!
case .REBIND: self = .init("REBIND")!
case .UNBIND: self = .init("UNBIND")!
case .REPORT: self = .init("REPORT")!
case .DELETE: self = .delete
case .UNLINK: self = .init("UNLINK")!
case .CONNECT: self = .connect
case .MSEARCH: self = .init("MSEARCH")!
case .OPTIONS: self = .options
case .PROPFIND: self = .init("PROPFIND")!
case .CHECKOUT: self = .init("CHECKOUT")!
case .PROPPATCH: self = .init("PROPPATCH")!
case .SUBSCRIBE: self = .init("SUBSCRIBE")!
case .MKCALENDAR: self = .init("MKCALENDAR")!
case .MKACTIVITY: self = .init("MKACTIVITY")!
case .UNSUBSCRIBE: self = .init("UNSUBSCRIBE")!
case .SOURCE: self = .init("SOURCE")!
case .RAW(value: let value):
guard let method = HTTPRequest.Method(value) else {
throw HTTP1TypeConversionError.invalidMethod
}
self = method
}
}
}

extension HTTPHeaders {
init(_ newFields: HTTPFields) {
let fields = newFields.map { ($0.name.rawName, $0.value) }
self.init(fields)
}
}

extension HTTPFields {
init(_ oldHeaders: HTTPHeaders, splitCookie: Bool) {
self.init()
self.reserveCapacity(count)
var firstHost = true
for field in oldHeaders {
if firstHost, field.name.lowercased() == "host" {
firstHost = false
continue
}
if let name = HTTPField.Name(field.name) {
if splitCookie, name == .cookie, #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) {
self.append(contentsOf: field.value.split(separator: "; ", omittingEmptySubsequences: false).map {
HTTPField(name: name, value: String($0))
})
} else {
self.append(HTTPField(name: name, value: field.value))
}
}
}
}
}

extension HTTPResponse {
init(_ oldResponse: HTTPResponseHead) throws {
guard oldResponse.status.code <= 999 else {
throw HTTP1TypeConversionError.invalidStatusCode
}
let status = HTTPResponse.Status(code: Int(oldResponse.status.code), reasonPhrase: oldResponse.status.reasonPhrase)
self.init(status: status, headerFields: HTTPFields(oldResponse.headers, splitCookie: false))
}
}
Loading

0 comments on commit 096df59

Please sign in to comment.