From e81b2c623d12d483c900969571851ecd629b88dd Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 21:33:17 -0300 Subject: [PATCH] fix: support request-header matching for stub-based playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matcher.headers([...]) compares the incoming request's headers against a candidate request. For stub-based playback (.replay(stubs:)), that candidate is synthesized from a Stub with no headers ever set on it — Stub.headers only becomes the *response* headers. So .headers([...]) could never usefully match: it would only pass when the real request's named headers were absent, making it unusable for asserting an expected header value against an in-memory stub. Add Stub.requestHeaders (defaulted, source-compatible with all existing initializers) and a matchingRequestHeaders(_:) fluent modifier to set it. Playback.makeEntry(from:) now carries those headers onto the synthesized candidate request, so .headers([...]) matches as expected. --- Sources/Replay/Matcher.swift | 4 ++ Sources/Replay/Playback.swift | 3 ++ Sources/Replay/Stub.swift | 28 ++++++++++++++ Tests/ReplayTests/PlaybackTests.swift | 54 +++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/Sources/Replay/Matcher.swift b/Sources/Replay/Matcher.swift index ed9f731..6b2d84e 100644 --- a/Sources/Replay/Matcher.swift +++ b/Sources/Replay/Matcher.swift @@ -40,6 +40,10 @@ public enum Matcher: Sendable { /// Matches the values of the specified HTTP request headers. /// /// Header name lookup uses `URLRequest.value(forHTTPHeaderField:)` semantics. + /// + /// - Important: For stub-based playback (`.replay(stubs:)`), a `Stub` has no expected + /// request headers by default — set them via `Stub.matchingRequestHeaders(_:)`, or this + /// matcher will only match requests where the named headers are absent. case headers([String]) /// Matches the raw HTTP body bytes (`URLRequest.httpBody`). diff --git a/Sources/Replay/Playback.swift b/Sources/Replay/Playback.swift index 0618566..ca30247 100644 --- a/Sources/Replay/Playback.swift +++ b/Sources/Replay/Playback.swift @@ -668,6 +668,9 @@ public actor PlaybackStore { private func makeEntry(from stub: Stub) throws -> HAR.Entry { var request = URLRequest(url: stub.url) request.httpMethod = stub.method.rawValue + if !stub.requestHeaders.isEmpty { + request.allHTTPHeaderFields = stub.requestHeaders + } guard let response = HTTPURLResponse( diff --git a/Sources/Replay/Stub.swift b/Sources/Replay/Stub.swift index fd0ea9b..4b3111c 100644 --- a/Sources/Replay/Stub.swift +++ b/Sources/Replay/Stub.swift @@ -79,6 +79,16 @@ public struct Stub: Sendable { public var headers: [String: String] public var body: Data? + /// Expected request headers, used by `Matcher.headers([...])` during stub-based playback. + /// + /// Unlike ``headers``, which becomes the *response* headers returned to the caller, + /// these are compared against the incoming request's headers. A stub with no + /// `requestHeaders` set (the default) is a no-op for `.headers` matching — the incoming + /// request's header values must be `nil` to match, which is rarely useful. Set this via + /// ``matchingRequestHeaders(_:)`` when you need to assert that a specific request header + /// (e.g. `Accept`, `Prefer`) was sent with a specific value. + public var requestHeaders: [String: String] = [:] + /// Initialize a stub with a specific method and URL. /// - Parameters: /// - method: HTTP method (default: .get). @@ -450,3 +460,21 @@ extension Stub { ) } } + +// MARK: - Request Header Matching + +extension Stub { + /// Returns a copy of this stub with expected request headers set, for use with + /// `Matcher.headers([...])`. + /// + /// - Parameters: + /// - headers: The request header names and values to match against the incoming + /// request. Only the header names you pass to `Matcher.headers([...])` are + /// actually compared, so it's safe to include more headers here than you match on. + /// - Returns: A copy of the stub with `requestHeaders` set. + public func matchingRequestHeaders(_ headers: [String: String]) -> Stub { + var copy = self + copy.requestHeaders = headers + return copy + } +} diff --git a/Tests/ReplayTests/PlaybackTests.swift b/Tests/ReplayTests/PlaybackTests.swift index fe1069d..2e428cb 100644 --- a/Tests/ReplayTests/PlaybackTests.swift +++ b/Tests/ReplayTests/PlaybackTests.swift @@ -643,6 +643,60 @@ struct PlaybackTests { #expect(String(data: data2, encoding: .utf8) == "Second") } + @Test("headers matcher rejects a stub with no expected request headers set") + func headersMatcherWithoutExpectedHeadersRejectsRequest() async throws { + let store = PlaybackStore() + let url = URL(string: "https://api.example.com/reports")! + let stubs: [Stub] = [.get(url.absoluteString, 200, [:], { "OK" })] + + try await store.configure( + PlaybackConfiguration( + source: .stubs(stubs), + matchers: [.method, .url, .headers(["Accept"])] + ) + ) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("text/csv", forHTTPHeaderField: "Accept") + + // Without `matchingRequestHeaders`, the stub's synthesized candidate request has no + // headers, so `.headers(["Accept"])` never matches a request that actually sets Accept. + await #expect(throws: Error.self) { + try await store.handleRequest(request) + } + } + + @Test("headers matcher matches a stub via matchingRequestHeaders") + func headersMatcherWithMatchingRequestHeaders() async throws { + let store = PlaybackStore() + let url = URL(string: "https://api.example.com/reports")! + let stubs: [Stub] = [ + .get(url.absoluteString, 200, [:], { "OK" }) + .matchingRequestHeaders(["Accept": "text/csv"]) + ] + + try await store.configure( + PlaybackConfiguration( + source: .stubs(stubs), + matchers: [.method, .url, .headers(["Accept"])] + ) + ) + + var matching = URLRequest(url: url) + matching.httpMethod = "GET" + matching.setValue("text/csv", forHTTPHeaderField: "Accept") + let (_, data) = try await store.handleRequest(matching) + #expect(String(data: data, encoding: .utf8) == "OK") + + var mismatched = URLRequest(url: url) + mismatched.httpMethod = "GET" + mismatched.setValue("application/json", forHTTPHeaderField: "Accept") + await #expect(throws: Error.self) { + try await store.handleRequest(mismatched) + } + } + @Test("strict mode throws for unmatched requests") func strictModeThrowsForUnmatched() async throws { let store = PlaybackStore()