Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/Replay/Matcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
3 changes: 3 additions & 0 deletions Sources/Replay/Playback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions Sources/Replay/Stub.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
}
}
54 changes: 54 additions & 0 deletions Tests/ReplayTests/PlaybackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading