From 55bbe27dfa1aa6a7ae45e11cd1f9268fe7c0f4fa Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 04:42:31 -0800 Subject: [PATCH 01/11] Add support for AsyncHTTPClient --- Package.resolved | 2 +- Package.swift | 15 +- Sources/Replay/HTTPClientProtocol.swift | 72 +++++ Sources/Replay/Playback.swift | 19 +- Sources/Replay/ReplayHTTPClient.swift | 255 +++++++++++++++ Tests/ReplayTests/ReplayHTTPClientTests.swift | 304 ++++++++++++++++++ 6 files changed, 663 insertions(+), 4 deletions(-) create mode 100644 Sources/Replay/HTTPClientProtocol.swift create mode 100644 Sources/Replay/ReplayHTTPClient.swift create mode 100644 Tests/ReplayTests/ReplayHTTPClientTests.swift diff --git a/Package.resolved b/Package.resolved index 615b2ce..27d20a2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7655b325d7d9e1d7b5368a5d35647ed4dbcccb9535f040ae5b0ba1068acbd793", + "originHash" : "9961ffc3943d2364fe0e75bcc089b11e2751a3177e5569d17ed8ca4fee37c6a2", "pins" : [ { "identity" : "swift-argument-parser", diff --git a/Package.swift b/Package.swift index fc20738..e3f55b9 100644 --- a/Package.swift +++ b/Package.swift @@ -23,12 +23,23 @@ let package = Package( targets: ["ReplayPlugin"] ), ], + traits: [ + .trait(name: "AsyncHTTPClient") + ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0") + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.24.0"), ], targets: [ .target( - name: "Replay" + name: "Replay", + dependencies: [ + .product( + name: "AsyncHTTPClient", + package: "async-http-client", + condition: .when(traits: ["AsyncHTTPClient"]) + ) + ] ), .executableTarget( name: "ReplayCLI", diff --git a/Sources/Replay/HTTPClientProtocol.swift b/Sources/Replay/HTTPClientProtocol.swift new file mode 100644 index 0000000..7a58aea --- /dev/null +++ b/Sources/Replay/HTTPClientProtocol.swift @@ -0,0 +1,72 @@ +#if canImport(AsyncHTTPClient) + + import AsyncHTTPClient + import NIOCore + + /// A protocol abstracting over `HTTPClient` for testability. + /// + /// Conform to this protocol to enable VCR-style recording and playback + /// of HTTP traffic without requiring a live network connection. + /// + /// `HTTPClient` conforms to this protocol out of the box. + /// In tests, use ``ReplayHTTPClient`` as a drop-in replacement + /// to replay responses from HAR archives. + /// + /// ## Example + /// + /// ```swift + /// // Production code accepts any HTTPClientProtocol + /// func fetchUser(using client: some HTTPClientProtocol) async throws -> User { + /// let request = HTTPClientRequest(url: "https://api.example.com/user") + /// let response = try await client.execute(request, timeout: .seconds(30)) + /// let body = try await response.body.collect(upTo: 1024 * 1024) + /// return try JSONDecoder().decode(User.self, from: body) + /// } + /// + /// // In tests, swap in a ReplayHTTPClient: + /// let client = try await ReplayHTTPClient( + /// configuration: PlaybackConfiguration(source: .file(archiveURL)) + /// ) + /// let user = try await fetchUser(using: client) + /// ``` + public protocol HTTPClientProtocol: Sendable { + /// Execute an HTTP request with a deadline. + /// + /// - Parameters: + /// - request: The HTTP request to execute. + /// - deadline: Point in time by which the request must complete. + /// - Returns: The HTTP response. + func execute( + _ request: HTTPClientRequest, + deadline: NIODeadline + ) async throws -> HTTPClientResponse + + /// Execute an HTTP request with a timeout. + /// + /// - Parameters: + /// - request: The HTTP request to execute. + /// - timeout: Maximum time the request may take. + /// - Returns: The HTTP response. + func execute( + _ request: HTTPClientRequest, + timeout: TimeAmount + ) async throws -> HTTPClientResponse + } + + extension HTTPClient: HTTPClientProtocol { + public func execute( + _ request: HTTPClientRequest, + deadline: NIODeadline + ) async throws -> HTTPClientResponse { + try await execute(request, deadline: deadline, logger: nil) + } + + public func execute( + _ request: HTTPClientRequest, + timeout: TimeAmount + ) async throws -> HTTPClientResponse { + try await execute(request, timeout: timeout, logger: nil) + } + } + +#endif // canImport(AsyncHTTPClient) diff --git a/Sources/Replay/Playback.swift b/Sources/Replay/Playback.swift index 1ca70c8..0618566 100644 --- a/Sources/Replay/Playback.swift +++ b/Sources/Replay/Playback.swift @@ -434,7 +434,7 @@ public actor PlaybackStore { activeStreamingProtocols[id] = nil } - private var configuration: PlaybackConfiguration? + internal private(set) var configuration: PlaybackConfiguration? private var entries: [HAR.Entry] = [] private var recordingEnabled: Bool = false private var effectivePlaybackMode: Replay.PlaybackMode = .strict @@ -589,6 +589,23 @@ public actor PlaybackStore { } } + /// Records a pre-built HAR entry. + /// + /// This method appends the entry to the store and persists it + /// when the source is a file. Filters are **not** applied; + /// callers are responsible for filtering before calling this method. + /// + /// - Parameter entry: The entry to record. + public func recordEntry(_ entry: HAR.Entry) throws { + entries.append(entry) + + if let config = configuration, case .file(let url) = config.source { + var log = (try? HAR.load(from: url)) ?? HAR.create() + log.entries = entries + try HAR.save(log, to: url) + } + } + /// Handles a URL request using the current playback configuration. /// /// In `.strict` playback mode, this method throws when no matching entry is found. diff --git a/Sources/Replay/ReplayHTTPClient.swift b/Sources/Replay/ReplayHTTPClient.swift new file mode 100644 index 0000000..e09e747 --- /dev/null +++ b/Sources/Replay/ReplayHTTPClient.swift @@ -0,0 +1,255 @@ +#if canImport(AsyncHTTPClient) + + import AsyncHTTPClient + import Foundation + import NIOCore + import NIOHTTP1 + + #if canImport(FoundationNetworking) + import FoundationNetworking + #endif + + /// An ``HTTPClientProtocol`` implementation that replays HTTP responses + /// from recorded HAR archives or in-memory stubs. + /// + /// `ReplayHTTPClient` delegates to Replay's ``PlaybackStore`` for request matching + /// and response lookup. In `.passthrough` or `.live` playback mode, + /// unmatched requests are forwarded to a real `HTTPClient`. + /// + /// ## Usage + /// + /// ```swift + /// let client = try await ReplayHTTPClient( + /// configuration: PlaybackConfiguration( + /// source: .file(archiveURL), + /// playbackMode: .strict + /// ) + /// ) + /// + /// let request = HTTPClientRequest(url: "https://api.example.com/data") + /// let response = try await client.execute(request, timeout: .seconds(30)) + /// ``` + public final class ReplayHTTPClient: HTTPClientProtocol { + private let store: PlaybackStore + private let liveClient: HTTPClient? + + /// Creates a replay client with the given playback configuration. + /// + /// - Parameters: + /// - configuration: The playback configuration controlling source, mode, matchers, and filters. + /// - liveClient: An optional `HTTPClient` used for network requests in `.passthrough` or `.live` mode. + public init( + configuration: PlaybackConfiguration, + liveClient: HTTPClient? = nil + ) async throws { + self.store = PlaybackStore() + self.liveClient = liveClient + try await store.configure(configuration) + } + + /// Creates a replay client from in-memory stubs. + /// + /// - Parameters: + /// - stubs: The stubs to use for playback. + /// - matchers: Matchers used to match incoming requests to stubs. + public init( + stubs: [Stub], + matchers: [Matcher] = .default + ) async throws { + self.store = PlaybackStore() + self.liveClient = nil + try await store.configure( + PlaybackConfiguration( + source: .stubs(stubs), + playbackMode: .strict, + recordMode: .none, + matchers: matchers + ) + ) + } + + public func execute( + _ request: HTTPClientRequest, + deadline: NIODeadline + ) async throws -> HTTPClientResponse { + try await handle(request) + } + + public func execute( + _ request: HTTPClientRequest, + timeout: TimeAmount + ) async throws -> HTTPClientResponse { + try await handle(request) + } + + // MARK: - Private + + private func handle(_ request: HTTPClientRequest) async throws -> HTTPClientResponse { + let urlRequest = try await URLRequest(from: request) + let disposition = try await store.checkRequest(urlRequest) + + switch disposition { + case .recorded(_, let data): + guard + let entry = await store.configuration?.matchers.firstMatch( + for: urlRequest, + in: await store.getAvailableEntries() + ) + else { + throw ReplayError.invalidResponse + } + return HTTPClientResponse(entry: entry, data: data) + + case .error(let error): + throw error + + case .network(let shouldRecord): + guard let client = liveClient else { + throw ReplayError.notConfigured + } + + let startTime = Date() + let response = try await client.execute(request, timeout: .seconds(30)) + let body = try await response.body.collect(upTo: 10 * 1024 * 1024) + let data = Data(buffer: body) + let duration = Date().timeIntervalSince(startTime) + + if shouldRecord { + var entry = try HAR.Entry( + clientRequest: request, + status: Int(response.status.code), + responseHeaders: response.headers, + data: data, + startTime: startTime, + duration: duration + ) + + if let filters = await store.configuration?.filters { + for filter in filters { + entry = await filter.apply(to: entry) + } + } + + try await store.recordEntry(entry) + } + + return response + } + } + } + + // MARK: - Conversions + + extension URLRequest { + /// Creates a `URLRequest` from an `HTTPClientRequest`. + init(from request: HTTPClientRequest) async throws { + guard let url = URL(string: request.url) else { + throw ReplayError.invalidURL(request.url) + } + + self.init(url: url) + self.httpMethod = request.method.rawValue + + for header in request.headers { + self.addValue(header.value, forHTTPHeaderField: header.name) + } + + if let body = request.body { + var collected = ByteBuffer() + for try await var chunk in body { + collected.writeBuffer(&chunk) + } + self.httpBody = Data(buffer: collected) + } + } + } + + extension HTTPClientResponse { + /// Creates an `HTTPClientResponse` from a HAR entry's response data. + init(entry: HAR.Entry, data: Data) { + var headers = HTTPHeaders() + for header in entry.response.headers { + headers.add(name: header.name, value: header.value) + } + + self.init( + status: HTTPResponseStatus(statusCode: entry.response.status), + headers: headers, + body: data.isEmpty ? .init() : .bytes(ByteBuffer(data: data)) + ) + } + } + + extension HAR.Entry { + /// Creates a HAR entry from an `HTTPClientRequest` and response metadata. + init( + clientRequest request: HTTPClientRequest, + status: Int, + responseHeaders: HTTPHeaders, + data: Data, + startTime: Date, + duration: TimeInterval + ) throws { + guard URL(string: request.url) != nil else { + throw ReplayError.invalidURL(request.url) + } + + self.startedDateTime = startTime + self.time = Int(duration * 1000) + + // Build HAR request + var harHeaders: [HAR.Header] = request.headers.map { + HAR.Header(name: $0.name, value: $0.value) + } + harHeaders.sort { $0.name < $1.name } + + let components = URLComponents(string: request.url) + let queryString: [HAR.QueryParameter] = + components?.queryItems?.map { item in + HAR.QueryParameter(name: item.name, value: item.value ?? "") + } ?? [] + + self.request = HAR.Request( + method: request.method.rawValue, + url: request.url, + httpVersion: "HTTP/1.1", + headers: harHeaders, + queryString: queryString, + headersSize: -1, + bodySize: 0 + ) + + // Build HAR response + var harResponseHeaders: [HAR.Header] = responseHeaders.map { + HAR.Header(name: $0.name, value: $0.value) + } + harResponseHeaders.sort { $0.name < $1.name } + + let mimeType = responseHeaders.first(name: "Content-Type") ?? "application/octet-stream" + let utf8Text = String(data: data, encoding: .utf8) + let encoding = utf8Text == nil && !data.isEmpty ? "base64" : nil + + self.response = HAR.Response( + status: status, + statusText: HTTPResponseStatus(statusCode: status).reasonPhrase, + httpVersion: "HTTP/1.1", + headers: harResponseHeaders, + content: HAR.Content( + size: data.count, + mimeType: mimeType, + text: utf8Text ?? data.base64EncodedString(), + encoding: encoding + ), + bodySize: data.count + ) + + self.cache = nil + self.timings = HAR.Timings( + send: 0, + wait: Int(duration * 1000), + receive: 0 + ) + } + } + +#endif // canImport(AsyncHTTPClient) diff --git a/Tests/ReplayTests/ReplayHTTPClientTests.swift b/Tests/ReplayTests/ReplayHTTPClientTests.swift new file mode 100644 index 0000000..aae1e99 --- /dev/null +++ b/Tests/ReplayTests/ReplayHTTPClientTests.swift @@ -0,0 +1,304 @@ +#if canImport(AsyncHTTPClient) + + import AsyncHTTPClient + import Foundation + import NIOCore + import NIOHTTP1 + import Testing + + @testable import Replay + + @Suite("ReplayHTTPClient Tests") + struct ReplayHTTPClientTests { + + // MARK: - HTTPClientProtocol Conformance + + @Test("HTTPClient conforms to HTTPClientProtocol") + func httpClientConformance() { + // Compile-time check: HTTPClient conforms to HTTPClientProtocol + func acceptsProtocol(_: some HTTPClientProtocol) {} + let client = HTTPClient() + acceptsProtocol(client) + try? client.syncShutdown() + } + + @Test("ReplayHTTPClient conforms to HTTPClientProtocol") + func replayClientConformance() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub(.get, "https://example.com", status: 200, body: "OK") + ] + ) + + func acceptsProtocol(_: some HTTPClientProtocol) {} + acceptsProtocol(client) + } + + // MARK: - Stub-Based Playback + + @Test("replays GET request from stubs") + func replaysGetFromStubs() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub( + .get, "https://api.example.com/data", status: 200, + headers: ["Content-Type": "application/json"], body: "{\"ok\":true}") + ] + ) + + var request = HTTPClientRequest(url: "https://api.example.com/data") + request.method = .GET + + let response = try await client.execute(request, timeout: .seconds(5)) + + #expect(response.status == .ok) + let body = try await response.body.collect(upTo: 1024) + let text = String(buffer: body) + #expect(text == "{\"ok\":true}") + } + + @Test("replays POST request from stubs") + func replaysPostFromStubs() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub(.post, "https://api.example.com/users", status: 201, body: "Created") + ] + ) + + var request = HTTPClientRequest(url: "https://api.example.com/users") + request.method = .POST + + let response = try await client.execute(request, timeout: .seconds(5)) + + #expect(response.status == .created) + } + + @Test("replays multiple stubs matched by URL") + func replaysMultipleStubs() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub(.get, "https://api.example.com/first", status: 200, body: "First"), + Stub(.get, "https://api.example.com/second", status: 200, body: "Second"), + ] + ) + + let response1 = try await client.execute( + HTTPClientRequest(url: "https://api.example.com/first"), + timeout: .seconds(5) + ) + let body1 = try await response1.body.collect(upTo: 1024) + + let response2 = try await client.execute( + HTTPClientRequest(url: "https://api.example.com/second"), + timeout: .seconds(5) + ) + let body2 = try await response2.body.collect(upTo: 1024) + + #expect(String(buffer: body1) == "First") + #expect(String(buffer: body2) == "Second") + } + + // MARK: - Entry-Based Playback + + @Test("replays from HAR entries") + func replaysFromEntries() async throws { + let entry = HAR.Entry( + startedDateTime: Date(), + time: 100, + request: HAR.Request( + method: "GET", + url: "https://api.example.com/status", + httpVersion: "HTTP/1.1", + headers: [], + bodySize: 0 + ), + response: HAR.Response( + status: 204, + statusText: "No Content", + httpVersion: "HTTP/1.1", + headers: [], + content: HAR.Content(size: 0, mimeType: "text/plain", text: ""), + bodySize: 0 + ), + timings: HAR.Timings(send: 0, wait: 100, receive: 0) + ) + + let client = try await ReplayHTTPClient( + configuration: PlaybackConfiguration(source: .entries([entry])) + ) + + let response = try await client.execute( + HTTPClientRequest(url: "https://api.example.com/status"), + timeout: .seconds(5) + ) + + #expect(response.status == .noContent) + } + + // MARK: - Response Headers + + @Test("preserves response headers from stubs") + func preservesResponseHeaders() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub( + .get, + "https://api.example.com/data", + status: 200, + headers: [ + "Content-Type": "application/json", + "X-Custom": "test-value", + ], + body: "{}" + ) + ] + ) + + let response = try await client.execute( + HTTPClientRequest(url: "https://api.example.com/data"), + timeout: .seconds(5) + ) + + #expect(response.headers.first(name: "X-Custom") == "test-value") + } + + // MARK: - Strict Mode + + @Test("strict mode throws for unmatched requests") + func strictModeThrows() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub(.get, "https://expected.com", status: 200, body: "OK") + ] + ) + + await #expect(throws: ReplayError.self) { + _ = try await client.execute( + HTTPClientRequest(url: "https://unexpected.com"), + timeout: .seconds(5) + ) + } + } + + // MARK: - Deadline-Based Execute + + @Test("execute with deadline works") + func executeWithDeadline() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub(.get, "https://api.example.com/data", status: 200, body: "OK") + ] + ) + + let response = try await client.execute( + HTTPClientRequest(url: "https://api.example.com/data"), + deadline: .now() + .seconds(5) + ) + + #expect(response.status == .ok) + } + + // MARK: - Empty Body + + @Test("handles empty response body") + func handlesEmptyBody() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub(.delete, "https://api.example.com/item/1", status: 204) + ] + ) + + var request = HTTPClientRequest(url: "https://api.example.com/item/1") + request.method = .DELETE + + let response = try await client.execute(request, timeout: .seconds(5)) + + #expect(response.status == .noContent) + let body = try await response.body.collect(upTo: 1024) + #expect(body.readableBytes == 0) + } + + // MARK: - Conversion Tests + + @Suite("Conversion Tests") + struct ConversionTests { + @Test("URLRequest from HTTPClientRequest preserves method and URL") + func urlRequestFromHTTPClientRequest() async throws { + var request = HTTPClientRequest(url: "https://api.example.com/users?page=1") + request.method = .POST + request.headers.add(name: "Content-Type", value: "application/json") + + let urlRequest = try await URLRequest(from: request) + + #expect(urlRequest.httpMethod == "POST") + #expect(urlRequest.url?.absoluteString == "https://api.example.com/users?page=1") + #expect(urlRequest.value(forHTTPHeaderField: "Content-Type") == "application/json") + } + + @Test("URLRequest from HTTPClientRequest preserves body") + func urlRequestPreservesBody() async throws { + var request = HTTPClientRequest(url: "https://api.example.com/data") + request.method = .POST + request.body = .bytes(ByteBuffer(string: "hello")) + + let urlRequest = try await URLRequest(from: request) + + #expect(urlRequest.httpBody == "hello".data(using: .utf8)) + } + + @Test("HTTPClientResponse from HAR entry preserves status and headers") + func httpClientResponseFromEntry() { + let entry = HAR.Entry( + startedDateTime: Date(), + time: 50, + request: HAR.Request( + method: "GET", + url: "https://example.com", + httpVersion: "HTTP/1.1", + headers: [], + bodySize: 0 + ), + response: HAR.Response( + status: 201, + statusText: "Created", + httpVersion: "HTTP/1.1", + headers: [ + HAR.Header(name: "X-Request-Id", value: "abc123") + ], + content: HAR.Content(size: 4, mimeType: "text/plain", text: "done"), + bodySize: 4 + ), + timings: HAR.Timings(send: 0, wait: 50, receive: 0) + ) + + let response = HTTPClientResponse(entry: entry, data: "done".data(using: .utf8)!) + + #expect(response.status == .created) + #expect(response.headers.first(name: "X-Request-Id") == "abc123") + } + + @Test("HAR.Entry from HTTPClientRequest captures method and URL") + func harEntryFromClientRequest() throws { + var request = HTTPClientRequest(url: "https://api.example.com/users?page=2") + request.method = .POST + request.headers.add(name: "Authorization", value: "Bearer token") + + let entry = try HAR.Entry( + clientRequest: request, + status: 200, + responseHeaders: HTTPHeaders([("Content-Type", "application/json")]), + data: "{\"ok\":true}".data(using: .utf8)!, + startTime: Date(), + duration: 0.5 + ) + + #expect(entry.request.method == "POST") + #expect(entry.request.url == "https://api.example.com/users?page=2") + #expect(entry.response.status == 200) + #expect(entry.response.content.text == "{\"ok\":true}") + #expect(entry.time == 500) + } + } + } + +#endif // canImport(AsyncHTTPClient) From 8e338ed36f323476c2474f4bbe63095b8334a577 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 04:45:54 -0800 Subject: [PATCH 02/11] Consolidate HTTPClient implementation into single file --- Package.resolved | 200 +++++++++++++++++- Sources/Replay/HTTPClientProtocol.swift | 72 ------- ...TPClient.swift => Replay+HTTPClient.swift} | 70 ++++++ 3 files changed, 269 insertions(+), 73 deletions(-) delete mode 100644 Sources/Replay/HTTPClientProtocol.swift rename Sources/Replay/{ReplayHTTPClient.swift => Replay+HTTPClient.swift} (77%) diff --git a/Package.resolved b/Package.resolved index 27d20a2..0e16a7f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "9961ffc3943d2364fe0e75bcc089b11e2751a3177e5569d17ed8ca4fee37c6a2", + "originHash" : "010a57d2488e3560fedb5d9c42f29c9d38b42ce67a6c94a0949600c8fec05406", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "52ed9d172018e31f2dbb46f0d4f58d66e13c281e", + "version" : "1.31.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -9,6 +27,186 @@ "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", "version" : "1.6.2" } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "2971dd5d9f6e0515664b01044826bcea16e59fac", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "24ccdeeeed4dfaae7955fcac9dbf5489ed4f1a25", + "version" : "1.18.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "1bb939fe7bbb00b8f8bab664cc90020c035c08d9", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "e109d8b5308d0e05201d9a1dd1c475446a946a11", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "9b92dcd5c22ae17016ad867852e0850f1f9f93ed", + "version" : "2.94.1" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b", + "version" : "1.32.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "979f431f1f1e75eb61562440cb2862a70d791d3d", + "version" : "1.39.1" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.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", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } } ], "version" : 3 diff --git a/Sources/Replay/HTTPClientProtocol.swift b/Sources/Replay/HTTPClientProtocol.swift deleted file mode 100644 index 7a58aea..0000000 --- a/Sources/Replay/HTTPClientProtocol.swift +++ /dev/null @@ -1,72 +0,0 @@ -#if canImport(AsyncHTTPClient) - - import AsyncHTTPClient - import NIOCore - - /// A protocol abstracting over `HTTPClient` for testability. - /// - /// Conform to this protocol to enable VCR-style recording and playback - /// of HTTP traffic without requiring a live network connection. - /// - /// `HTTPClient` conforms to this protocol out of the box. - /// In tests, use ``ReplayHTTPClient`` as a drop-in replacement - /// to replay responses from HAR archives. - /// - /// ## Example - /// - /// ```swift - /// // Production code accepts any HTTPClientProtocol - /// func fetchUser(using client: some HTTPClientProtocol) async throws -> User { - /// let request = HTTPClientRequest(url: "https://api.example.com/user") - /// let response = try await client.execute(request, timeout: .seconds(30)) - /// let body = try await response.body.collect(upTo: 1024 * 1024) - /// return try JSONDecoder().decode(User.self, from: body) - /// } - /// - /// // In tests, swap in a ReplayHTTPClient: - /// let client = try await ReplayHTTPClient( - /// configuration: PlaybackConfiguration(source: .file(archiveURL)) - /// ) - /// let user = try await fetchUser(using: client) - /// ``` - public protocol HTTPClientProtocol: Sendable { - /// Execute an HTTP request with a deadline. - /// - /// - Parameters: - /// - request: The HTTP request to execute. - /// - deadline: Point in time by which the request must complete. - /// - Returns: The HTTP response. - func execute( - _ request: HTTPClientRequest, - deadline: NIODeadline - ) async throws -> HTTPClientResponse - - /// Execute an HTTP request with a timeout. - /// - /// - Parameters: - /// - request: The HTTP request to execute. - /// - timeout: Maximum time the request may take. - /// - Returns: The HTTP response. - func execute( - _ request: HTTPClientRequest, - timeout: TimeAmount - ) async throws -> HTTPClientResponse - } - - extension HTTPClient: HTTPClientProtocol { - public func execute( - _ request: HTTPClientRequest, - deadline: NIODeadline - ) async throws -> HTTPClientResponse { - try await execute(request, deadline: deadline, logger: nil) - } - - public func execute( - _ request: HTTPClientRequest, - timeout: TimeAmount - ) async throws -> HTTPClientResponse { - try await execute(request, timeout: timeout, logger: nil) - } - } - -#endif // canImport(AsyncHTTPClient) diff --git a/Sources/Replay/ReplayHTTPClient.swift b/Sources/Replay/Replay+HTTPClient.swift similarity index 77% rename from Sources/Replay/ReplayHTTPClient.swift rename to Sources/Replay/Replay+HTTPClient.swift index e09e747..a5d6f7c 100644 --- a/Sources/Replay/ReplayHTTPClient.swift +++ b/Sources/Replay/Replay+HTTPClient.swift @@ -9,6 +9,76 @@ import FoundationNetworking #endif + // MARK: - HTTPClientProtocol + + /// A protocol abstracting over `HTTPClient` for testability. + /// + /// Conform to this protocol to enable VCR-style recording and playback + /// of HTTP traffic without requiring a live network connection. + /// + /// `HTTPClient` conforms to this protocol out of the box. + /// In tests, use ``ReplayHTTPClient`` as a drop-in replacement + /// to replay responses from HAR archives. + /// + /// ## Example + /// + /// ```swift + /// // Production code accepts any HTTPClientProtocol + /// func fetchUser(using client: some HTTPClientProtocol) async throws -> User { + /// let request = HTTPClientRequest(url: "https://api.example.com/user") + /// let response = try await client.execute(request, timeout: .seconds(30)) + /// let body = try await response.body.collect(upTo: 1024 * 1024) + /// return try JSONDecoder().decode(User.self, from: body) + /// } + /// + /// // In tests, swap in a ReplayHTTPClient: + /// let client = try await ReplayHTTPClient( + /// configuration: PlaybackConfiguration(source: .file(archiveURL)) + /// ) + /// let user = try await fetchUser(using: client) + /// ``` + public protocol HTTPClientProtocol: Sendable { + /// Execute an HTTP request with a deadline. + /// + /// - Parameters: + /// - request: The HTTP request to execute. + /// - deadline: Point in time by which the request must complete. + /// - Returns: The HTTP response. + func execute( + _ request: HTTPClientRequest, + deadline: NIODeadline + ) async throws -> HTTPClientResponse + + /// Execute an HTTP request with a timeout. + /// + /// - Parameters: + /// - request: The HTTP request to execute. + /// - timeout: Maximum time the request may take. + /// - Returns: The HTTP response. + func execute( + _ request: HTTPClientRequest, + timeout: TimeAmount + ) async throws -> HTTPClientResponse + } + + extension HTTPClient: HTTPClientProtocol { + public func execute( + _ request: HTTPClientRequest, + deadline: NIODeadline + ) async throws -> HTTPClientResponse { + try await execute(request, deadline: deadline, logger: nil) + } + + public func execute( + _ request: HTTPClientRequest, + timeout: TimeAmount + ) async throws -> HTTPClientResponse { + try await execute(request, timeout: timeout, logger: nil) + } + } + + // MARK: - ReplayHTTPClient + /// An ``HTTPClientProtocol`` implementation that replays HTTP responses /// from recorded HAR archives or in-memory stubs. /// From 6910db5c25bb550afa5388ae687a40dae6354eeb Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 04:48:49 -0800 Subject: [PATCH 03/11] Update CI workflow --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fd34b7..e3538f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,13 +41,13 @@ jobs: run: swift format lint --strict --recursive . - name: Build - run: swift build -v + run: swift build --traits AsyncHTTPClient - name: Smoke test plugin run: swift package plugin --allow-writing-to-package-directory replay --help - name: Test - run: swift test -v + run: swift test --traits AsyncHTTPClient test-linux: name: Swift ${{ matrix.swift-version }} on Linux @@ -72,10 +72,10 @@ jobs: run: swift format lint --strict --recursive . - name: Build - run: swift build -v + run: swift build --traits AsyncHTTPClient - name: Smoke test plugin run: swift package plugin --allow-writing-to-package-directory replay --help - name: Test - run: swift test -v + run: swift test --traits AsyncHTTPClient From 0e1def528f26961fb78087fd53aaa1567fa99592 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 04:52:46 -0800 Subject: [PATCH 04/11] Update README with information about Async HTTP Client --- README.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bd24f2b..1a64a97 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ Add to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/mattt/Replay.git", from: "0.2.0") + .package(url: "https://github.com/mattt/Replay.git", from: "0.3.0") ] ``` @@ -146,6 +146,22 @@ Then add `Replay` to your **test target** dependencies: ) ``` +#### AsyncHTTPClient support + +Replay can also intercept requests made with +[AsyncHTTPClient](https://github.com/swift-server/async-http-client). +Enable the `AsyncHTTPClient` package trait: + +```swift +dependencies: [ + .package( + url: "https://github.com/mattt/Replay.git", + from: "0.3.0", + traits: ["AsyncHTTPClient"] + ) +] +``` + ### Xcode 1. Add the package: **File → Add Packages…** @@ -475,6 +491,78 @@ Open the Network tab, trigger the requests, then export as HAR: > Browser-exported HAR files often contain sensitive data (cookies, tokens, PII). > Always review and redact before committing. +### AsyncHTTPClient + +When the `AsyncHTTPClient` trait is enabled, +Replay provides `HTTPClientProtocol` — a protocol that both +`HTTPClient` and `ReplayHTTPClient` conform to. +Design your code against `some HTTPClientProtocol` +and swap in `ReplayHTTPClient` during tests: + +```swift +import AsyncHTTPClient +import NIOCore + +actor ExampleAPIClient { + let httpClient: any HTTPClientProtocol + + init(httpClient: any HTTPClientProtocol) { + self.httpClient = httpClient + } + + func fetchUser(id: Int) async throws -> User { + let request = HTTPClientRequest(url: "https://api.example.com/users/\(id)") + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let body = try await response.body.collect(upTo: 1024 * 1024) + return try JSONDecoder().decode(User.self, from: body) + } +} +``` + +In tests, use `ReplayHTTPClient` with HAR files or inline stubs: + +```swift +import Testing +import Replay + +@Test("fetch user from stub") +func fetchUser() async throws { + let client = try await ReplayHTTPClient( + stubs: [ + Stub( + .get, + "https://api.example.com/users/42", + status: 200, + headers: ["Content-Type": "application/json"], + body: #"{"id":42,"name":"Alice"}"# + ) + ] + ) + + let api = ExampleAPIClient(httpClient: client) + let user = try await api.fetchUser(id: 42) + #expect(user.name == "Alice") +} +``` + +`ReplayHTTPClient` also accepts a `PlaybackConfiguration` +for HAR-file-based playback: + +```swift +let client = try await ReplayHTTPClient( + configuration: PlaybackConfiguration( + source: .file(archiveURL), + playbackMode: .strict, + matchers: [.method, .path] + ) +) +``` + +> [!NOTE] +> `AsyncHTTPClient` uses SwiftNIO for networking rather than Foundation's URL Loading System, +> so `URLProtocol`-based interception (used by `@Test(.replay(…))`) cannot intercept its traffic. +> The `HTTPClientProtocol` abstraction provides an equivalent mechanism through dependency injection. + ### Using Replay without Swift Testing For XCTest or manual control, use the lower-level APIs directly: From f6007206741959da2ef6336c42cc66224169734e Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 05:10:43 -0800 Subject: [PATCH 05/11] Update CI workflow to build without traits --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3538f6..6d5ceaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: run: swift format lint --strict --recursive . - name: Build - run: swift build --traits AsyncHTTPClient + run: swift build - name: Smoke test plugin run: swift package plugin --allow-writing-to-package-directory replay --help @@ -72,7 +72,7 @@ jobs: run: swift format lint --strict --recursive . - name: Build - run: swift build --traits AsyncHTTPClient + run: swift build - name: Smoke test plugin run: swift package plugin --allow-writing-to-package-directory replay --help From 8d48e360427ca739a0eaf4b185a5010a2b89a3cf Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 05:23:33 -0800 Subject: [PATCH 06/11] Incorporate feedback from review --- Sources/Replay/Replay+HTTPClient.swift | 104 +++++++++++------- Tests/ReplayTests/ReplayHTTPClientTests.swift | 47 +++----- 2 files changed, 83 insertions(+), 68 deletions(-) diff --git a/Sources/Replay/Replay+HTTPClient.swift b/Sources/Replay/Replay+HTTPClient.swift index a5d6f7c..6191e83 100644 --- a/Sources/Replay/Replay+HTTPClient.swift +++ b/Sources/Replay/Replay+HTTPClient.swift @@ -142,33 +142,44 @@ _ request: HTTPClientRequest, deadline: NIODeadline ) async throws -> HTTPClientResponse { - try await handle(request) + try await handle(request, deadline: deadline) } public func execute( _ request: HTTPClientRequest, timeout: TimeAmount ) async throws -> HTTPClientResponse { - try await handle(request) + try await handle(request, deadline: .now() + timeout) } // MARK: - Private - private func handle(_ request: HTTPClientRequest) async throws -> HTTPClientResponse { - let urlRequest = try await URLRequest(from: request) + private func handle( + _ request: HTTPClientRequest, + deadline: NIODeadline + ) async throws -> HTTPClientResponse { + guard URL(string: request.url) != nil else { + throw ReplayError.invalidURL(request.url) + } + + // Materialize the body once so it can be used for both matching and forwarding. + var materializedRequest = request + var bodyData: Data? + if let body = request.body { + var collected = ByteBuffer() + for try await var chunk in body { + collected.writeBuffer(&chunk) + } + bodyData = Data(buffer: collected) + materializedRequest.body = .bytes(collected) + } + + let urlRequest = URLRequest(from: materializedRequest, bodyData: bodyData) let disposition = try await store.checkRequest(urlRequest) switch disposition { - case .recorded(_, let data): - guard - let entry = await store.configuration?.matchers.firstMatch( - for: urlRequest, - in: await store.getAvailableEntries() - ) - else { - throw ReplayError.invalidResponse - } - return HTTPClientResponse(entry: entry, data: data) + case .recorded(let response, let data): + return HTTPClientResponse(response: response, data: data) case .error(let error): throw error @@ -179,14 +190,16 @@ } let startTime = Date() - let response = try await client.execute(request, timeout: .seconds(30)) + let response = try await client.execute( + materializedRequest, deadline: deadline) let body = try await response.body.collect(upTo: 10 * 1024 * 1024) let data = Data(buffer: body) let duration = Date().timeIntervalSince(startTime) if shouldRecord { var entry = try HAR.Entry( - clientRequest: request, + clientRequest: materializedRequest, + bodyData: bodyData, status: Int(response.status.code), responseHeaders: response.headers, data: data, @@ -203,7 +216,11 @@ try await store.recordEntry(entry) } - return response + return HTTPClientResponse( + status: response.status, + headers: response.headers, + body: .bytes(ByteBuffer(data: data)) + ) } } } @@ -211,39 +228,30 @@ // MARK: - Conversions extension URLRequest { - /// Creates a `URLRequest` from an `HTTPClientRequest`. - init(from request: HTTPClientRequest) async throws { - guard let url = URL(string: request.url) else { - throw ReplayError.invalidURL(request.url) - } - - self.init(url: url) + /// Creates a `URLRequest` from an `HTTPClientRequest` with pre-materialized body data. + init(from request: HTTPClientRequest, bodyData: Data?) { + // Force-unwrap is safe: handle(_:) validated the URL before calling this. + self.init(url: URL(string: request.url)!) self.httpMethod = request.method.rawValue for header in request.headers { self.addValue(header.value, forHTTPHeaderField: header.name) } - if let body = request.body { - var collected = ByteBuffer() - for try await var chunk in body { - collected.writeBuffer(&chunk) - } - self.httpBody = Data(buffer: collected) - } + self.httpBody = bodyData } } extension HTTPClientResponse { - /// Creates an `HTTPClientResponse` from a HAR entry's response data. - init(entry: HAR.Entry, data: Data) { + /// Creates an `HTTPClientResponse` from an `HTTPURLResponse` and body data. + init(response: HTTPURLResponse, data: Data) { var headers = HTTPHeaders() - for header in entry.response.headers { - headers.add(name: header.name, value: header.value) + for (key, value) in response.allHeaderFields { + headers.add(name: String(describing: key), value: String(describing: value)) } self.init( - status: HTTPResponseStatus(statusCode: entry.response.status), + status: HTTPResponseStatus(statusCode: response.statusCode), headers: headers, body: data.isEmpty ? .init() : .bytes(ByteBuffer(data: data)) ) @@ -252,8 +260,18 @@ extension HAR.Entry { /// Creates a HAR entry from an `HTTPClientRequest` and response metadata. + /// + /// - Parameters: + /// - request: The original HTTP client request. + /// - bodyData: Pre-materialized request body data, if any. + /// - status: The HTTP response status code. + /// - responseHeaders: The HTTP response headers. + /// - data: The response body data. + /// - startTime: When the request started. + /// - duration: How long the request took. init( clientRequest request: HTTPClientRequest, + bodyData: Data? = nil, status: Int, responseHeaders: HTTPHeaders, data: Data, @@ -279,14 +297,26 @@ HAR.QueryParameter(name: item.name, value: item.value ?? "") } ?? [] + var postData: HAR.PostData? + if let bodyData, !bodyData.isEmpty { + let contentType = + request.headers.first(name: "Content-Type") ?? "application/octet-stream" + let utf8Text = String(data: bodyData, encoding: .utf8) + postData = HAR.PostData( + mimeType: contentType, + text: utf8Text ?? bodyData.base64EncodedString() + ) + } + self.request = HAR.Request( method: request.method.rawValue, url: request.url, httpVersion: "HTTP/1.1", headers: harHeaders, queryString: queryString, + postData: postData, headersSize: -1, - bodySize: 0 + bodySize: bodyData?.count ?? 0 ) // Build HAR response diff --git a/Tests/ReplayTests/ReplayHTTPClientTests.swift b/Tests/ReplayTests/ReplayHTTPClientTests.swift index aae1e99..83f080f 100644 --- a/Tests/ReplayTests/ReplayHTTPClientTests.swift +++ b/Tests/ReplayTests/ReplayHTTPClientTests.swift @@ -223,12 +223,12 @@ @Suite("Conversion Tests") struct ConversionTests { @Test("URLRequest from HTTPClientRequest preserves method and URL") - func urlRequestFromHTTPClientRequest() async throws { + func urlRequestFromHTTPClientRequest() { var request = HTTPClientRequest(url: "https://api.example.com/users?page=1") request.method = .POST request.headers.add(name: "Content-Type", value: "application/json") - let urlRequest = try await URLRequest(from: request) + let urlRequest = URLRequest(from: request, bodyData: nil) #expect(urlRequest.httpMethod == "POST") #expect(urlRequest.url?.absoluteString == "https://api.example.com/users?page=1") @@ -236,42 +236,27 @@ } @Test("URLRequest from HTTPClientRequest preserves body") - func urlRequestPreservesBody() async throws { + func urlRequestPreservesBody() { var request = HTTPClientRequest(url: "https://api.example.com/data") request.method = .POST - request.body = .bytes(ByteBuffer(string: "hello")) - let urlRequest = try await URLRequest(from: request) + let bodyData = "hello".data(using: .utf8) + let urlRequest = URLRequest(from: request, bodyData: bodyData) - #expect(urlRequest.httpBody == "hello".data(using: .utf8)) + #expect(urlRequest.httpBody == bodyData) } - @Test("HTTPClientResponse from HAR entry preserves status and headers") - func httpClientResponseFromEntry() { - let entry = HAR.Entry( - startedDateTime: Date(), - time: 50, - request: HAR.Request( - method: "GET", - url: "https://example.com", - httpVersion: "HTTP/1.1", - headers: [], - bodySize: 0 - ), - response: HAR.Response( - status: 201, - statusText: "Created", - httpVersion: "HTTP/1.1", - headers: [ - HAR.Header(name: "X-Request-Id", value: "abc123") - ], - content: HAR.Content(size: 4, mimeType: "text/plain", text: "done"), - bodySize: 4 - ), - timings: HAR.Timings(send: 0, wait: 50, receive: 0) - ) + @Test("HTTPClientResponse from HTTPURLResponse preserves status and headers") + func httpClientResponseFromHTTPURLResponse() { + let httpResponse = HTTPURLResponse( + url: URL(string: "https://example.com")!, + statusCode: 201, + httpVersion: "HTTP/1.1", + headerFields: ["X-Request-Id": "abc123"] + )! - let response = HTTPClientResponse(entry: entry, data: "done".data(using: .utf8)!) + let response = HTTPClientResponse( + response: httpResponse, data: "done".data(using: .utf8)!) #expect(response.status == .created) #expect(response.headers.first(name: "X-Request-Id") == "abc123") From 07b2c4f3f0bba498a94f38f674b14162427bd91e Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 05:28:06 -0800 Subject: [PATCH 07/11] Fix build failures on Linux --- Sources/Replay/Replay+HTTPClient.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Replay/Replay+HTTPClient.swift b/Sources/Replay/Replay+HTTPClient.swift index 6191e83..93faf4e 100644 --- a/Sources/Replay/Replay+HTTPClient.swift +++ b/Sources/Replay/Replay+HTTPClient.swift @@ -170,7 +170,7 @@ for try await var chunk in body { collected.writeBuffer(&chunk) } - bodyData = Data(buffer: collected) + bodyData = Data(collected.readableBytesView) materializedRequest.body = .bytes(collected) } @@ -193,7 +193,7 @@ let response = try await client.execute( materializedRequest, deadline: deadline) let body = try await response.body.collect(upTo: 10 * 1024 * 1024) - let data = Data(buffer: body) + let data = Data(body.readableBytesView) let duration = Date().timeIntervalSince(startTime) if shouldRecord { @@ -219,7 +219,7 @@ return HTTPClientResponse( status: response.status, headers: response.headers, - body: .bytes(ByteBuffer(data: data)) + body: .bytes(ByteBuffer(bytes: data)) ) } } @@ -253,7 +253,7 @@ self.init( status: HTTPResponseStatus(statusCode: response.statusCode), headers: headers, - body: data.isEmpty ? .init() : .bytes(ByteBuffer(data: data)) + body: data.isEmpty ? .init() : .bytes(ByteBuffer(bytes: data)) ) } } From ffac4af6023418627869f0ed7b60a8f3c2b2953b Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 05:35:41 -0800 Subject: [PATCH 08/11] Update CI workflow to include trait in matrix --- .github/workflows/ci.yml | 58 +++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d5ceaf..a647dc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: test-macos: - name: Swift ${{ matrix.swift }} on macOS ${{ matrix.macos }} with Xcode ${{ matrix.xcode }} + name: Swift ${{ matrix.swift }} on macOS ${{ matrix.macos }} with Xcode ${{ matrix.xcode }} (${{ matrix.variant }}) runs-on: macos-${{ matrix.macos }} env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer" @@ -19,9 +19,19 @@ jobs: - swift: "6.1" xcode: "16.3" macos: "15" + variant: default - swift: "6.2" xcode: "26.0" macos: "26" + variant: default + - swift: "6.1" + xcode: "16.3" + macos: "15" + variant: async_http_client + - swift: "6.2" + xcode: "26.0" + macos: "26" + variant: async_http_client timeout-minutes: 10 steps: - name: Checkout code @@ -33,31 +43,45 @@ jobs: path: | ~/.cache/org.swift.swiftpm .build - key: ${{ runner.os }}-swift-${{ matrix.swift }}-spm-${{ hashFiles('**/Package.resolved') }} + key: ${{ runner.os }}-swift-${{ matrix.swift }}-${{ matrix.variant }}-spm-${{ hashFiles('**/Package.resolved') }} restore-keys: | - ${{ runner.os }}-swift-${{ matrix.swift }}-spm- + ${{ runner.os }}-swift-${{ matrix.swift }}-${{ matrix.variant }}-spm- + + - name: Configure variant + run: | + if [ "${{ matrix.variant }}" = "async_http_client" ]; then + echo "SWIFT_VARIANT_ARGS=--traits AsyncHTTPClient --scratch-path .build/async-http-client" >> "$GITHUB_ENV" + else + echo "SWIFT_VARIANT_ARGS=--scratch-path .build/default" >> "$GITHUB_ENV" + fi - name: Lint run: swift format lint --strict --recursive . - name: Build - run: swift build + run: swift build $SWIFT_VARIANT_ARGS - name: Smoke test plugin - run: swift package plugin --allow-writing-to-package-directory replay --help + run: swift package $SWIFT_VARIANT_ARGS plugin --allow-writing-to-package-directory replay --help - name: Test - run: swift test --traits AsyncHTTPClient + run: swift test $SWIFT_VARIANT_ARGS test-linux: - name: Swift ${{ matrix.swift-version }} on Linux + name: Swift ${{ matrix.swift-version }} on Linux (${{ matrix.variant }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - swift-version: - - "6.1.3" - - "6.2.3" + include: + - swift-version: "6.1.3" + variant: default + - swift-version: "6.2.3" + variant: default + - swift-version: "6.1.3" + variant: async_http_client + - swift-version: "6.2.3" + variant: async_http_client timeout-minutes: 10 steps: - name: Checkout code @@ -68,14 +92,22 @@ jobs: with: toolchain: ${{ matrix.swift-version }} + - name: Configure variant + run: | + if [ "${{ matrix.variant }}" = "async_http_client" ]; then + echo "SWIFT_VARIANT_ARGS=--traits AsyncHTTPClient --scratch-path .build/async-http-client" >> "$GITHUB_ENV" + else + echo "SWIFT_VARIANT_ARGS=--scratch-path .build/default" >> "$GITHUB_ENV" + fi + - name: Lint run: swift format lint --strict --recursive . - name: Build - run: swift build + run: swift build $SWIFT_VARIANT_ARGS - name: Smoke test plugin - run: swift package plugin --allow-writing-to-package-directory replay --help + run: swift package $SWIFT_VARIANT_ARGS plugin --allow-writing-to-package-directory replay --help - name: Test - run: swift test --traits AsyncHTTPClient + run: swift test $SWIFT_VARIANT_ARGS From d71a8bd2bb03ad6751adb8235549b90061589b04 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 05:40:45 -0800 Subject: [PATCH 09/11] Fix plugin smoke test step --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a647dc4..4372371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,8 @@ jobs: run: swift build $SWIFT_VARIANT_ARGS - name: Smoke test plugin - run: swift package $SWIFT_VARIANT_ARGS plugin --allow-writing-to-package-directory replay --help + if: matrix.variant == 'default' + run: swift package --scratch-path .build/default plugin --allow-writing-to-package-directory replay --help - name: Test run: swift test $SWIFT_VARIANT_ARGS @@ -107,7 +108,8 @@ jobs: run: swift build $SWIFT_VARIANT_ARGS - name: Smoke test plugin - run: swift package $SWIFT_VARIANT_ARGS plugin --allow-writing-to-package-directory replay --help + if: matrix.variant == 'default' + run: swift package --scratch-path .build/default plugin --allow-writing-to-package-directory replay --help - name: Test run: swift test $SWIFT_VARIANT_ARGS From 140bfa5b0d4c135cf0616c6cda0b9f38c4fb5040 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 05:43:22 -0800 Subject: [PATCH 10/11] Improve aesthetics of job names --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4372371..3432c2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: test-macos: - name: Swift ${{ matrix.swift }} on macOS ${{ matrix.macos }} with Xcode ${{ matrix.xcode }} (${{ matrix.variant }}) + name: Swift ${{ matrix.swift }} on macOS ${{ matrix.macos }} with Xcode ${{ matrix.xcode }}${{ matrix.variant == 'async_http_client' && ' (AsyncHTTPClient)' || '' }} runs-on: macos-${{ matrix.macos }} env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer" @@ -69,7 +69,7 @@ jobs: run: swift test $SWIFT_VARIANT_ARGS test-linux: - name: Swift ${{ matrix.swift-version }} on Linux (${{ matrix.variant }}) + name: Swift ${{ matrix.swift-version }} on Linux${{ matrix.variant == 'async_http_client' && ' (AsyncHTTPClient)' || '' }} runs-on: ubuntu-latest strategy: fail-fast: false From 4f1927278fb6476424bfb6251bddeaaef120c8a9 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 17 Feb 2026 06:09:50 -0800 Subject: [PATCH 11/11] Add missing FoundationNetwork import --- Tests/ReplayTests/ReplayHTTPClientTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/ReplayTests/ReplayHTTPClientTests.swift b/Tests/ReplayTests/ReplayHTTPClientTests.swift index 83f080f..dea7054 100644 --- a/Tests/ReplayTests/ReplayHTTPClientTests.swift +++ b/Tests/ReplayTests/ReplayHTTPClientTests.swift @@ -2,6 +2,9 @@ import AsyncHTTPClient import Foundation + #if canImport(FoundationNetworking) + import FoundationNetworking + #endif import NIOCore import NIOHTTP1 import Testing