From 9e9fbe3d44380cecf10fb2467860f5f42fbf9231 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 9 Jul 2026 17:14:41 -0400 Subject: [PATCH] chore: Port ContractTestService from Kitura to Hummingbird Kitura is archived and its transitive BlueSSLService uses the OpenSSL 1.1 `SSL_get_peer_certificate` API removed in OpenSSL 3, so the contract-test service could not build on a modern Linux (Ubuntu 24.04 / OpenSSL 3) swift container. Replace it with Hummingbird (swift-nio based, vendored BoringSSL). The SDK-facing logic (CreateStreamReq, CallbackForwarder, EventSource usage) is unchanged; only the HTTP server layer moves: - The 4 routes port to Hummingbird's async router. - Stream state moves to an actor (EventSource is Sendable). - DELETE / stops the service via graceful ServiceGroup shutdown. - URLSession/URLRequest are gated behind FoundationNetworking for Linux. Verified: builds in the swift:6.1 Linux container (which Kitura could not) and smoke-tested (GET / capabilities, 404/400 paths, graceful shutdown via DELETE /). CI is unchanged: contract-tests still runs on macOS and validates the full protocol against this service. --- ContractTestService/Package.resolved | 191 ++++++++++++++---- ContractTestService/Package.swift | 22 +- .../Sources/ContractTestService/main.swift | 122 +++++++---- 3 files changed, 246 insertions(+), 89 deletions(-) diff --git a/ContractTestService/Package.resolved b/ContractTestService/Package.resolved index af40f25..8263fbd 100644 --- a/ContractTestService/Package.resolved +++ b/ContractTestService/Package.resolved @@ -1,84 +1,112 @@ { + "originHash" : "b0d80bffe78ca591543fd81e5d3205bacdceec518a2114a199abf8a5b6235311", "pins" : [ { - "identity" : "bluesignals", + "identity" : "async-http-client", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/BlueSignals.git", + "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "1f6c49e186c8a4eeef87ba14f2f97b8646559d13", - "version" : "1.0.200" + "revision" : "4603a8036d921ea999fadb742931546c341f4bd7", + "version" : "1.35.0" } }, { - "identity" : "bluesocket", + "identity" : "hummingbird", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/BlueSocket.git", + "location" : "https://github.com/hummingbird-project/hummingbird.git", "state" : { - "revision" : "c9894fd117457f1d006575fbfb2fdfd6f79eac03", - "version" : "1.0.200" + "revision" : "eebb5ecf8a39beea87401f014fdf87f0ce5b159d", + "version" : "2.25.0" } }, { - "identity" : "bluesslservice", + "identity" : "swift-algorithms", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/BlueSSLService.git", + "location" : "https://github.com/apple/swift-algorithms.git", "state" : { - "revision" : "ae5889d2a8b068d2d3ab91ec73aa8f557c03cd8a", - "version" : "1.0.200" + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" } }, { - "identity" : "kitura", + "identity" : "swift-asn1", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/Kitura", + "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "daf6479097469a0c1fe64755db6e23f788a3ec29", - "version" : "2.9.200" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { - "identity" : "kitura-net", + "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/Kitura-net.git", + "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "e017a57772eb477c294d72b25ab9ae38d25539f5", - "version" : "2.4.200" + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" } }, { - "identity" : "kitura-templateengine", + "identity" : "swift-atomics", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/Kitura-TemplateEngine.git", + "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "1670b09bfb63b66f7dffff627a06b50492485407", - "version" : "2.0.200" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { - "identity" : "kituracontracts", + "identity" : "swift-certificates", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/KituraContracts.git", + "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "8a4778c3aa7833e9e1af884e8819d436c237cd70", - "version" : "1.2.201" + "revision" : "89fbc3714264cce8db8e4ec51b64e01c3e28c6c5", + "version" : "1.19.3" } }, { - "identity" : "loggerapi", + "identity" : "swift-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/LoggerAPI.git", + "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "e82d34eab3f0b05391082b11ea07d3b70d2f65bb", - "version" : "1.9.200" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { - "identity" : "openssl", + "identity" : "swift-crypto", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/OpenSSL.git", + "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "5dc8cb4f971135c17343e3c6df4f28904a0600e2", - "version" : "2.3.1" + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" } }, { @@ -91,14 +119,95 @@ } }, { - "identity" : "typedecoder", + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd3e1152083706d77b223fb29110e590efcc70c0", + "version" : "2.101.2" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "88a51340f59cf181ebde888bd1b749296b3ec029", + "version" : "1.34.3" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", + "version" : "2.37.1" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/TypeDecoder.git", + "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "28ec01815c0aea9236f92982ca8d351e7112a4a0", - "version" : "1.3.201" + "revision" : "b5544ba79a70a0cb3563e75bf26dc198d6b40ed3", + "version" : "1.7.4" } } ], - "version" : 2 + "version" : 3 } diff --git a/ContractTestService/Package.swift b/ContractTestService/Package.swift index e635d98..30515c7 100644 --- a/ContractTestService/Package.swift +++ b/ContractTestService/Package.swift @@ -1,14 +1,14 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "ContractTestService", platforms: [ - .iOS(.v16), - .macOS(.v13), - .watchOS(.v9), - .tvOS(.v16), + // Hummingbird's Router/Application are @available(macOS 14, iOS 17, tvOS 17); no watchOS. + .macOS(.v14), + .iOS(.v17), + .tvOS(.v17), ], products: [ .executable( @@ -19,14 +19,20 @@ let package = Package( dependencies: [ // Local dependency to LDSwiftEventSource .package(name: "LDSwiftEventSource", path: ".."), - .package(url: "https://github.com/Kitura/Kitura", from: "2.9.200") + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0") ], targets: [ - .target( + .executableTarget( name: "ContractTestService", dependencies: [ .product(name: "LDSwiftEventSource", package: "LDSwiftEventSource"), - "Kitura" + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "Logging", package: "swift-log"), + .product(name: "HTTPTypes", package: "swift-http-types") ] ) ] diff --git a/ContractTestService/Sources/ContractTestService/main.swift b/ContractTestService/Sources/ContractTestService/main.swift index adcbf7c..c8fe563 100644 --- a/ContractTestService/Sources/ContractTestService/main.swift +++ b/ContractTestService/Sources/ContractTestService/main.swift @@ -1,13 +1,22 @@ -import Dispatch import Foundation -import Kitura +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import HTTPTypes +import Hummingbird import LDSwiftEventSource +import Logging +import ServiceLifecycle -struct StatusResp: Encodable { +struct StatusResp: ResponseEncodable { let name = "swift-eventsource" let capabilities = ["server-directed-shutdown-request", "comments", "headers", "last-event-id", "post", "read-timeout", "report"] } +struct MessageResponse: ResponseEncodable { + let message: String +} + struct CreateStreamReq: Decodable { let streamUrl: URL let callbackUrl: URL @@ -82,56 +91,89 @@ struct CallbackForwarder: Sendable { } } -let stateQueue = DispatchQueue(label: "StateQueue") -var nextId: Int = 0 -var state: [String: EventSource] = [:] +// Tracks the active `EventSource` streams by their control-path id. `EventSource` is Sendable, +// so an actor gives race-free access from the concurrent request handlers. +actor StreamStore { + private var nextId = 0 + private var streams: [String: EventSource] = [:] + + func add(_ es: EventSource) -> String { + let id = String(nextId) + nextId += 1 + streams[id] = es + return "/control/\(id)" + } + + func remove(id: String) -> EventSource? { + streams.removeValue(forKey: id) + } +} + +// Lets the `DELETE /` route stop the running service. The trigger is installed once the +// service group exists (after the routes are declared), so it is set behind a lock. +final class ShutdownHandle: @unchecked Sendable { + private let lock = NSLock() + private var handler: (@Sendable () async -> Void)? + + func set(_ handler: @escaping @Sendable () async -> Void) { + lock.withLock { self.handler = handler } + } + + func fire() async { + let handler = lock.withLock { self.handler } + await handler?() + } +} + +let store = StreamStore() +let shutdown = ShutdownHandle() let router = Router() -router.get("/") { _, resp, next in - resp.send(StatusResp()) - next() +router.get("/") { _, _ in + StatusResp() } -router.delete("/") { _, resp, next in - resp.send(["message": "Shutting down contract test service"]) - next() - Kitura.stop() +router.delete("/") { _, _ in + // Stop the service once this response has been written; graceful shutdown drains the + // in-flight request first. + Task { await shutdown.fire() } + return MessageResponse(message: "Shutting down contract test service") } -router.post("/") { req, resp, next in - guard let createStreamReq = try? req.read(as: CreateStreamReq.self) - else { - resp.status(.badRequest).send(["message": "Body of POST to '/' invalid"]) - return next() - } +router.post("/") { request, context -> Response in + let createStreamReq = try await request.decode(as: CreateStreamReq.self, context: context) let es = EventSource(config: createStreamReq.createEventSourceConfig()) let forwarder = CallbackForwarder(baseUrl: createStreamReq.callbackUrl) - let stream = es.events - let location: String = stateQueue.sync { - state[String(nextId)] = es - nextId += 1 - return "/control/\(nextId - 1)" - } + let location = await store.add(es) // The consumer task drains the stream until `es.stop()` finishes it. - Task { await forwarder.consume(stream) } + Task { await forwarder.consume(es.events) } es.start() - resp.headers["Location"] = location - resp.send(["message": "Created test service entity at \(location)"]) - next() + var response = try MessageResponse(message: "Created test service entity at \(location)") + .response(from: request, context: context) + response.headers[.location] = location + return response } -router.delete("/control/:id") { req, resp, next in - stateQueue.sync { - if let es = state.removeValue(forKey: req.parameters["id"]!) { - es.stop() - resp.send(["message": "Shut down test service entity at \(req.matchedPath)"]) - } else { - resp.status(.notFound).send(["message": "Test service entity not found at \(req.matchedPath)"]) - } +router.delete("/control/:id") { _, context -> MessageResponse in + guard let id = context.parameters.get("id"), let es = await store.remove(id: id) else { + throw HTTPError(.notFound, message: "Test service entity not found") } - next() + es.stop() + return MessageResponse(message: "Shut down test service entity at /control/\(id)") } -Kitura.addHTTPServer(onPort: 8000, onAddress: "localhost", with: router) -Kitura.run() +let app = Application( + router: router, + configuration: .init(address: .hostname("127.0.0.1", port: 8000)) +) + +let serviceGroup = ServiceGroup( + configuration: .init( + services: [app], + gracefulShutdownSignals: [.sigterm, .sigint], + logger: Logger(label: "contract-test-service") + ) +) +shutdown.set { await serviceGroup.triggerGracefulShutdown() } +try await serviceGroup.run()