680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
204 lines
7.4 KiB
Swift
204 lines
7.4 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
import Foundation
|
|
import NIOConcurrencyHelpers
|
|
import NIOCore
|
|
import NIOHTTP1
|
|
import NIOPosix
|
|
|
|
// MARK: - StubRequest / StubResponse
|
|
|
|
/// An inbound HTTP request captured by the stub server.
|
|
struct StubRequest: Sendable {
|
|
let method: HTTPMethod
|
|
let uri: String
|
|
let body: Data
|
|
let headers: HTTPHeaders
|
|
}
|
|
|
|
/// A canned HTTP response produced by the stub server.
|
|
struct StubResponse: Sendable {
|
|
let status: HTTPResponseStatus
|
|
let body: Data
|
|
let headers: HTTPHeaders
|
|
|
|
static func ok(_ body: Data = .init()) -> StubResponse {
|
|
StubResponse(status: .ok, body: body, headers: [:])
|
|
}
|
|
|
|
static func json<T: Encodable>(_ value: T) throws -> StubResponse {
|
|
let data = try JSONEncoder().encode(value)
|
|
var headers = HTTPHeaders()
|
|
headers.add(name: "Content-Type", value: "application/json")
|
|
return StubResponse(status: .ok, body: data, headers: headers)
|
|
}
|
|
|
|
static func status(_ status: HTTPResponseStatus, body: Data = .init()) -> StubResponse {
|
|
StubResponse(status: status, body: body, headers: [:])
|
|
}
|
|
}
|
|
|
|
// MARK: - StubHTTPServer
|
|
|
|
/// An in-process HTTP/1.1 server bound to a Unix Domain Socket, used in tests.
|
|
///
|
|
/// Example:
|
|
/// ```swift
|
|
/// let server = try await StubHTTPServer(eventLoopGroup: group) { req in
|
|
/// return StubResponse.ok(Data("{}".utf8))
|
|
/// }
|
|
/// defer { Task { try? await server.shutdown() } }
|
|
/// ```
|
|
final class StubHTTPServer: Sendable {
|
|
/// The path to the Unix Domain Socket this server is bound to.
|
|
let socketPath: String
|
|
|
|
private let channel: Channel
|
|
/// Recorded requests, protected by a lock so the test thread can read safely.
|
|
private let requests: NIOLockedValueBox<[StubRequest]>
|
|
|
|
init(
|
|
eventLoopGroup: any EventLoopGroup,
|
|
handler: @escaping @Sendable (StubRequest) -> StubResponse
|
|
) async throws {
|
|
let sockPath = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("ch-stub-\(UUID().uuidString).sock")
|
|
.path
|
|
|
|
let requestsBox = NIOLockedValueBox<[StubRequest]>([])
|
|
|
|
let bootstrap = ServerBootstrap(group: eventLoopGroup)
|
|
.serverChannelOption(.backlog, value: 256)
|
|
.serverChannelOption(.socketOption(.so_reuseaddr), value: 1)
|
|
.childChannelInitializer { channel in
|
|
channel.eventLoop.makeCompletedFuture {
|
|
try channel.pipeline.syncOperations.configureHTTPServerPipeline(
|
|
withPipeliningAssistance: false
|
|
)
|
|
try channel.pipeline.syncOperations.addHandler(
|
|
StubRequestHandler(userHandler: handler, requests: requestsBox)
|
|
)
|
|
}
|
|
}
|
|
|
|
let boundChannel =
|
|
try await bootstrap
|
|
.bind(unixDomainSocketPath: sockPath, cleanupExistingSocketFile: true)
|
|
.get()
|
|
|
|
self.socketPath = sockPath
|
|
self.channel = boundChannel
|
|
self.requests = requestsBox
|
|
}
|
|
|
|
/// Stop accepting connections and close the listening socket.
|
|
func shutdown() async throws {
|
|
try await channel.close().get()
|
|
try? FileManager.default.removeItem(atPath: socketPath)
|
|
}
|
|
|
|
/// Returns all requests recorded so far.
|
|
func recordedRequests() -> [StubRequest] {
|
|
requests.withLockedValue { $0 }
|
|
}
|
|
}
|
|
|
|
// MARK: - StubRequestHandler
|
|
|
|
/// Handles a single inbound HTTP/1.1 request, invokes the user handler, and
|
|
/// writes the stub response.
|
|
///
|
|
/// All ChannelHandler callbacks run on the channel's event loop, so the mutable
|
|
/// inbound-state fields need no external synchronisation. The shared `requests`
|
|
/// box is still locked because the test thread reads it from outside the loop.
|
|
private final class StubRequestHandler: ChannelInboundHandler, @unchecked Sendable {
|
|
typealias InboundIn = HTTPServerRequestPart
|
|
typealias OutboundOut = HTTPServerResponsePart
|
|
|
|
private let userHandler: @Sendable (StubRequest) -> StubResponse
|
|
private let requests: NIOLockedValueBox<[StubRequest]>
|
|
|
|
// Mutable inbound state — only touched on the event loop.
|
|
private var pendingMethod: HTTPMethod?
|
|
private var pendingURI: String?
|
|
private var pendingHeaders: HTTPHeaders = [:]
|
|
private var pendingBody: [UInt8] = []
|
|
|
|
init(
|
|
userHandler: @escaping @Sendable (StubRequest) -> StubResponse,
|
|
requests: NIOLockedValueBox<[StubRequest]>
|
|
) {
|
|
self.userHandler = userHandler
|
|
self.requests = requests
|
|
}
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
switch unwrapInboundIn(data) {
|
|
case .head(let head):
|
|
pendingMethod = head.method
|
|
pendingURI = head.uri
|
|
pendingHeaders = head.headers
|
|
pendingBody = []
|
|
case .body(var buf):
|
|
if let bytes = buf.readBytes(length: buf.readableBytes) {
|
|
pendingBody.append(contentsOf: bytes)
|
|
}
|
|
case .end:
|
|
guard let method = pendingMethod, let uri = pendingURI else {
|
|
context.close(promise: nil)
|
|
return
|
|
}
|
|
let request = StubRequest(
|
|
method: method,
|
|
uri: uri,
|
|
body: Data(pendingBody),
|
|
headers: pendingHeaders
|
|
)
|
|
requests.withLockedValue { $0.append(request) }
|
|
let stubResp = userHandler(request)
|
|
writeResponse(context: context, response: stubResp)
|
|
}
|
|
}
|
|
|
|
private func writeResponse(context: ChannelHandlerContext, response: StubResponse) {
|
|
var respHeaders = response.headers
|
|
respHeaders.replaceOrAdd(name: "Content-Length", value: "\(response.body.count)")
|
|
respHeaders.replaceOrAdd(name: "Connection", value: "close")
|
|
|
|
let head = HTTPResponseHead(version: .http1_1, status: response.status, headers: respHeaders)
|
|
context.write(wrapOutboundOut(.head(head)), promise: nil)
|
|
|
|
if !response.body.isEmpty {
|
|
var buf = context.channel.allocator.buffer(capacity: response.body.count)
|
|
buf.writeBytes(response.body)
|
|
context.write(wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil)
|
|
}
|
|
|
|
// Use NIOLoopBound to safely capture `context` in a @Sendable closure.
|
|
// The bound asserts event-loop access; the close runs on the same loop
|
|
// as the flush completion, which is correct.
|
|
let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop)
|
|
context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in
|
|
boundContext.value.close(promise: nil)
|
|
}
|
|
}
|
|
|
|
func errorCaught(context: ChannelHandlerContext, error: any Error) {
|
|
context.close(promise: nil)
|
|
}
|
|
}
|