From 17cfa7fc0d028384420996149810d137d909fd87 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 11:58:36 +0200 Subject: [PATCH 1/9] Add section about wildcards --- README.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/README.md b/README.md index c840c33..92caffa 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,110 @@ Path("/search", query: ["q": "swift"], matching: .contains) { } ``` +## Wildcard patterns +Both hosts and paths support wildcard matching using glob-style patterns. This is useful for mocking multiple similar endpoints without registering each variation individually. + +### Single segment wildcards (`*`) +Match within a single segment only. Segments are separated by `.` for hosts and `/` for paths. + +#### Host wildcards +```swift +HTTPMock.shared.registerResponses { + Host("*.example.com") { // Single host pattern wildcard. + Path("/users") { + MockResponse.plaintext("wildcard host") + } + } +} +``` + +The host pattern (`*.example.com`) matches i.e.: +- `api.example.com` +- `staging.example.com`. + +Since `*` only matches on a single segment this means the pattern will **NOT** match i.e. `api.staging.example.com`. + +#### Path wildcards +```swift +HTTPMock.shared.addResponses( + forPath: "/api/*/users", // Single path pattern wildcard. + host: "api.example.com", + responses: [.encodable(users)] +) +``` + +The path pattern (`/api/*/users`) matches i.e.: +- `/api/v1/users` +- `/api/v2/users`. + +Since `*` only matches on a single segment this means the pattern will **NOT** match i.e. `/api/v1/beta/users` + +### Multi-segment wildcards (`**`) +Match across multiple segments (zero or more). Useful for flexible host/path matching. + +#### Multi-segment host wildcards +```swift +HTTPMock.shared.registerResponses { + Host("**.example.com") { + Path("/api/**/data") { + MockResponse.plaintext("flexible matching") + } + } +} +``` + +The host pattern (`**.example.com`) matches i.e.: +- `api.example.com` +- `api.staging.example.com`. + +The path pattern (`/api/**/data`) matches i.e.: +- `/api/data` +- `/api/v1/data` +- `/api/v1/beta/data`. + +#### Complex patterns +```swift +HTTPMock.shared.addResponses( + forPath: "/api/**/users/*", + host: "api-*.example.com", + responses: [.encodable(users)] +) +``` + +The combination of host and path pattern will match i.e. `api-staging.example.com/api/v1/beta/users/123`. + +### Pattern specificity +When multiple patterns could match the same request, HTTPMock automatically chooses the most specific: + +1. **Exact matches** always win over wildcards. +2. **Fewer wildcards** beat more wildcards. +3. **Longer literal content** wins ties. + +Given the registered patterns in the code block below, the table explains which pattern(s) would match, and win, on an incoming request. + +```swift +Host("api.example.com") // exact - highest priority +Host("*.example.com") // single wildcard +Host("**.example.com") // multi wildcard - lowest priority +``` + +| Incoming request | Pattern matches | Winning pattern | Why? | +| :- | - | - | - | +| `api.example.com` | Matches on all three registered patterns | The exact pattern | It has no wildcards (lowest score). | +| `api-test.example.com` | Matches on both the single- and multi wildcard patterns | The single wildcard pattern | It has the fewest wildcards (lowest score). | +| `api.staging.example.com` | Matches only on the multi wildcard pattern | The multi wildcard pattern | The only pattern that matches. | + +#### Specificity tie-breaker + +Here's an example to provide more context to the specificity score when a tie between two patterns occurs. Given the registered patterns: + +```swift +Host("*.example.*") // two single wildcard +Host("**.example.com") // multi wildcard +``` + +An incoming request to **`api.example.com`** would match on both of the patterns above, but the winning pattern will be the **multi wildcard pattern**. Both patterns have exactly two wildcards, but the multi wildcard pattern has a **longer matching literal** which gives it a higher score. + ## File-based responses Serve response data directly from a file on disk. Useful for pre-recorded and/or large responses. Either specify the `Content-Type` manually, or let it be inferred from the file. From 2de6982d5367e3edb53409c29b463f33821259a0 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 11:58:57 +0200 Subject: [PATCH 2/9] Update highlights --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 92caffa..fba558b 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ A tiny, test-first way to mock `URLSession` — **fast to set up, easy to read, ## Highlights - **Two ways to add mocks**: a **clean DSL** or **single registration methods** — use whichever reads best for your use case. - **Instance or singleton**: you can either use the singleton `HTTPMock.shared` or create separate instances with `HTTPMock()`. Different instances have separate response queues. -- **Provides a real `URLSession`**: inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the code under test. -- **Precise matching**: host + path, plus optional **query matching** (`.exact` or `.contains`). +- **Provides a real `URLSession`**: inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the code under test. +- **Flexible matching**: exact strings, **wildcard patterns** (`*` and `**`), plus optional **query matching** (`.exact` or `.contains`). - **Headers support**: define headers at the host or path, with optional **cascade** to children when using the DSL. - **FIFO responses**: queue multiple responses and they'll be served in order. - **Passthrough networking**: configure unmocked requests to either return a hardcoded 404 or be passed through to the network. @@ -373,5 +373,6 @@ Path("/user") { - [X] Let user point to a file that should be served. - [X] Set delay on requests. - [X] Create separate instances of `HTTPMock`. The current single instance requires tests to be run in sequence, instead of parallel. +- [X] Support wildcard patterns in host and path matching (`*` and `**` glob-style patterns). - [ ] Let user configure a default "not found" response. Will be used either when no matching mocks are found or if queue is empty. - [ ] Does arrays in query parameters work? I think they're being overwritten with the current setup. From dc4d58f622f5451de0687afabcb5bc5f2dba8c18 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 11:59:06 +0200 Subject: [PATCH 3/9] Remove trailing whitespaces --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fba558b..713f083 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ HTTPMock.shared.registerResponses { Path("/data") { // Point to a file in the specified `Bundle`. MockResponse.file(named: "response", extension: "json", in: Bundle.main) - + // Load the contents of a file from a `URL`. MockResponse.file(url: urlToFile) } @@ -281,7 +281,7 @@ By default, unmocked requests return a hardcoded 404 response with a small body. HTTPMock.shared.unmockedPolicy = .notFound // Alternative: let unmocked requests hit the real network. -// This can be useful if you're doing integration testing and only want to mock certain endpoints. +// This can be useful if you're doing integration testing and only want to mock certain endpoints. HTTPMock.shared.unmockedPolicy = .passthrough ``` From 948f7d88aeab4f5b89e483aa82dfcdd604ac4277 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 12:04:12 +0200 Subject: [PATCH 4/9] Update FAQ --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 713f083..65cf81a 100644 --- a/README.md +++ b/README.md @@ -330,17 +330,25 @@ let instanceSession = mockInstance.urlSession ## FAQs **Can I run tests that use `HTTPMock` in parallel?** -Previously, only a single instance of `HTTPMock` could exist, so tests had to be run sequentially. Now, you can create multiple independent `HTTPMock` instances using `HTTPMock()`, allowing parallel tests or separate mock configurations. The singleton `HTTPMock.shared` still exists for convenience. +Yes. You can create multiple independent `HTTPMock` instances, which allows for parallel tests or separate mock configurations. If you don't need separate instances you can use the singleton `HTTPMock.shared`. + +Be aware that the singleton will exist for the whole duration of the app or tests, so call `HTTPMock.shared.clearQueues()` if you need to reset it. **Can I use my own `URLSession`?** -Yes — most tests just use `HTTPMock.shared.urlSession`. If your code constructs its own session, inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the component under test. +Yes. Most tests just use `HTTPMock.shared.urlSession`. If your code constructs its own session, inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the component under test. **Is order guaranteed?** -Yes, per (host, path, [query]) responses are popped in **FIFO** order. +Yes. Responses per (host, path, [query]) are queued and popped in **FIFO** order. **What happens if a request is not mocked?** By default, unmocked requests return a hardcoded "404 Not Found" response. You can configure `HTTPMock`'s `UnmockedPolicy` to instead pass such requests through to the real network, allowing unmocked calls to succeed. +**Can I mix exact and wildcard patterns for the same endpoint?** +Yes. You can register multiple patterns that could match the same request. HTTPMock will automatically choose the most specific pattern using a score ranking (exact beats wildcards, fewer wildcards beat more wildcards). + +**What characters are supported in wildcard patterns?** +Use `*` for single-segment wildcards and `**` for multi-segment wildcards. All other characters are treated as literals. Special regex characters are automatically escaped, so patterns like `api-*.example.com` work as expected. + ## Example response helpers These are available as static factory methods on `MockResponse` and can be used directly inside a `Path` or `addResponses` builder: From f570a75f55cd48065c153b191094250d06417741 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 12:11:58 +0200 Subject: [PATCH 5/9] Don't reuse same helper for creating mock key --- .../HTTPMockTests/HTTPMockMatcherTests.swift | 4 +- .../HTTPMockResultBuilderTests.swift | 48 ++++++++++----- Tests/HTTPMockTests/HTTPMockTests.swift | 58 ++++++++++++------- Tests/HTTPMockTests/Helpers/UtilMethods.swift | 11 ---- 4 files changed, 70 insertions(+), 51 deletions(-) delete mode 100644 Tests/HTTPMockTests/Helpers/UtilMethods.swift diff --git a/Tests/HTTPMockTests/HTTPMockMatcherTests.swift b/Tests/HTTPMockTests/HTTPMockMatcherTests.swift index 2694abb..17dd438 100644 --- a/Tests/HTTPMockTests/HTTPMockMatcherTests.swift +++ b/Tests/HTTPMockTests/HTTPMockMatcherTests.swift @@ -369,7 +369,7 @@ struct HTTPMockMatcherTests { // MARK: - Helpers - func makeKey( + private func makeKey( host: String = "api.example.com", path: String = "/search", queryItems: [String: String]? = nil, @@ -383,7 +383,7 @@ struct HTTPMockMatcherTests { ) } - func checkMatch( + private func checkMatch( host: String = "api.example.com", path: String = "/search", queryItems: [String: String] = [:], diff --git a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift index fd2a968..cdfc55a 100644 --- a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift +++ b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift @@ -34,8 +34,8 @@ struct HTTPMockResultBuilderTests { } let expectedQueues = [ - createMockKey(host: "domain.com", path: "/"), - createMockKey(host: "other-domain.com", path: "/") + makeKey(host: "domain.com", path: "/"), + makeKey(host: "other-domain.com", path: "/") ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -55,9 +55,9 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: Set = [ - createMockKey(path: "/root"), - createMockKey(path: "/root/child"), - createMockKey(path: "/root/child/grand-child"), + makeKey(path: "/root"), + makeKey(path: "/root/child"), + makeKey(path: "/root/child/grand-child"), ] #expect(mockQueues.count == 3) #expect(Set(mockQueues.keys) == expectedQueues) @@ -98,8 +98,8 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: [HTTPMockURLProtocol.Key] = [ - createMockKey(host: "example.com", path: "/"), - createMockKey(host: "example.com", path: "/some-other-path") + makeKey(host: "example.com", path: "/"), + makeKey(host: "example.com", path: "/some-other-path") ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -113,7 +113,7 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: [HTTPMockURLProtocol.Key] = [ - createMockKey(path: "/root") + makeKey(path: "/root") ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -166,11 +166,11 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: [HTTPMockURLProtocol.Key] = [ - createMockKey(host: "a", path: "/"), - createMockKey(host: "b", path: "/"), - createMockKey(host: "c", path: "/"), - createMockKey(host: "d", path: "/root"), - createMockKey(host: "e", path: "/root//////roooooot"), + makeKey(host: "a", path: "/"), + makeKey(host: "b", path: "/"), + makeKey(host: "c", path: "/"), + makeKey(host: "d", path: "/root"), + makeKey(host: "e", path: "/root//////roooooot"), ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -190,7 +190,7 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: [HTTPMockURLProtocol.Key] = [ - createMockKey(path: "/") + makeKey(path: "/") ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -269,7 +269,7 @@ struct HTTPMockResultBuilderTests { } } - let resp = try #require(mockQueues[createMockKey(host: "example.com", path: "/conflict")]?.first) + let resp = try #require(mockQueues[makeKey(host: "example.com", path: "/conflict")]?.first) #expect(resp.headers["X-Shared"] == "response") } @@ -296,7 +296,7 @@ struct HTTPMockResultBuilderTests { } } - let resp = try #require(mockQueues[createMockKey(path: "/pos")]?.first) + let resp = try #require(mockQueues[makeKey(path: "/pos")]?.first) #expect(resp.headers == ["K": "V"]) // Still applied } @@ -361,4 +361,20 @@ struct HTTPMockResultBuilderTests { #expect(response2.httpStatusCode == 200) #expect(data2.toString == "ok-contains-2") } + + // MARK: - Helpers + + private func makeKey( + host: String = "example.com", + path: String = "/", + queryItems: [String : String]? = nil, + queryMatching: QueryMatching = .exact + ) -> HTTPMockURLProtocol.Key { + HTTPMockURLProtocol.Key( + host: host, + path: path, + queryItems: queryItems, + queryMatching: queryMatching + ) + } } diff --git a/Tests/HTTPMockTests/HTTPMockTests.swift b/Tests/HTTPMockTests/HTTPMockTests.swift index a69f3a8..8fc9037 100644 --- a/Tests/HTTPMockTests/HTTPMockTests.swift +++ b/Tests/HTTPMockTests/HTTPMockTests.swift @@ -22,7 +22,7 @@ struct HTTPMockTests { // Add response using default domain. httpMock.addResponses(forPath: "/root", responses: [.empty()]) - let mockKey1 = createMockKey(path: "/root") + let mockKey1 = makeKey(path: "/root") #expect(mockQueues.count == 1) #expect(Set(mockQueues.keys) == Set([mockKey1])) @@ -30,7 +30,7 @@ struct HTTPMockTests { httpMock.defaultDomain = "other.example.com" httpMock.addResponses(forPath: "/root", responses: [.empty()]) - let mockKey2 = createMockKey(host: "other.example.com", path: "/root") + let mockKey2 = makeKey(host: "other.example.com", path: "/root") #expect(mockQueues.count == 2) #expect(Set(mockQueues.keys) == Set([mockKey1, mockKey2])) } @@ -46,7 +46,7 @@ struct HTTPMockTests { @Test func itDoesNotRegisterTheKeyIfNoResponsesAreProvided() { - let key = createMockKey() + let key = makeKey() httpMock.addResponses([], for: key) #expect(mockQueues.isEmpty) @@ -54,7 +54,7 @@ struct HTTPMockTests { @Test func itDoesNotRegisterResponsesWithInvalidLifetimes() { - let key = createMockKey() + let key = makeKey() httpMock.addResponses([ .empty(lifetime: .multiple(0)), .empty(lifetime: .multiple(-1)), @@ -65,7 +65,7 @@ struct HTTPMockTests { @Test func itAllowsRegisteringResponsesAfterAnEternalResponse() { - let key = createMockKey() + let key = makeKey() httpMock.addResponses([ .empty(lifetime: .eternal), .empty(lifetime: .single), @@ -93,7 +93,7 @@ struct HTTPMockTests { httpMock.clearQueue(forHost: "domain.com") #expect(mockQueues.count == 1) - #expect(mockQueues.first?.key == createMockKey(host: "example.com", path: "/root")) + #expect(mockQueues.first?.key == makeKey(host: "example.com", path: "/root")) } @Test @@ -109,8 +109,8 @@ struct HTTPMockTests { @Test func itStoresAddedResponsesInQueue() { - let mockKey1 = createMockKey(path: "/root") - let mockKey2 = createMockKey(path: "/root/leaf") + let mockKey1 = makeKey(path: "/root") + let mockKey2 = makeKey(path: "/root/leaf") httpMock.addResponse(.empty(), for: mockKey1) httpMock.addResponse(.plaintext("Hey!"), for: mockKey2) @@ -123,7 +123,7 @@ struct HTTPMockTests { @Test func itPopsFromQueueOnRequest() async throws { - let mockKey = createMockKey() + let mockKey = makeKey() httpMock.addResponse(.empty(), for: mockKey) #expect(mockQueues.count == 1) @@ -137,7 +137,7 @@ struct HTTPMockTests { @Test func itPopsInFifoOrderForSamePath() async throws { - let key = createMockKey(path: "/fifo") + let key = makeKey(path: "/fifo") httpMock.addResponse(.plaintext("one"), for: key) httpMock.addResponse(.plaintext("two"), for: key) httpMock.addResponse(.plaintext("three"), for: key) @@ -188,7 +188,7 @@ struct HTTPMockTests { let path = "/search" // Register a response that matches only exactly these params (no extras) - let exactKey = createMockKey( + let exactKey = makeKey( host: host, path: path, queryItems: ["q": "swift", "page": "1"], @@ -213,7 +213,7 @@ struct HTTPMockTests { let host = "example.com" let path = "/search" - let containsKey = createMockKey( + let containsKey = makeKey( host: host, path: path, queryItems: ["q": "swift"], @@ -239,7 +239,7 @@ struct HTTPMockTests { let host = "example.com" let path = "/search" - let containsKey = createMockKey( + let containsKey = makeKey( host: host, path: path, queryItems: ["q": "swift"], @@ -258,7 +258,7 @@ struct HTTPMockTests { let host = "example.com" let path = "/search" - let containsKey = createMockKey( + let containsKey = makeKey( host: host, path: path, queryItems: ["q": "swift", "page": "1"], @@ -292,7 +292,7 @@ struct HTTPMockTests { let contents = "{\"hello\":\"world\"}" let url = try writeTempFile(named: "fixture", ext: "json", contents: Data(contents.utf8)) - let key = createMockKey(host: host, path: path) + let key = makeKey(host: host, path: path) httpMock.addResponse(.file(url: url), for: key) let requestURL = try #require(URL(string: "https://\(host)\(path)")) @@ -311,7 +311,7 @@ struct HTTPMockTests { let contents = "BINARYDATA" let url = try writeTempFile(named: "blob", ext: "bin", contents: Data(contents.utf8)) - let key = createMockKey(host: host, path: path) + let key = makeKey(host: host, path: path) httpMock.addResponse( .file(url: url, status: .ok, headers: ["Cache-Control": "no-store"], contentType: "application/custom"), for: key @@ -331,7 +331,7 @@ struct HTTPMockTests { @Test func lifetime_single_isConsumedOnce() async throws { - let key = createMockKey(path: "/lifetime-single") + let key = makeKey(path: "/lifetime-single") httpMock.addResponse(.plaintext("once", lifetime: .single), for: key) #expect(mockQueues[key]?.count == 1) @@ -350,7 +350,7 @@ struct HTTPMockTests { @Test func lifetime_multiple_isConsumedNTimes_thenRemoved() async throws { - let key = createMockKey(path: "/lifetime-multi") + let key = makeKey(path: "/lifetime-multi") httpMock.addResponse(.plaintext("multi", lifetime: .multiple(3)), for: key) #expect(mockQueues[key]?.count == 1) @@ -374,7 +374,7 @@ struct HTTPMockTests { @Test func lifetime_eternal_isNeverRemoved() async throws { - let key = createMockKey(path: "/lifetime-eternal") + let key = makeKey(path: "/lifetime-eternal") httpMock.addResponse(.plaintext("eternal", lifetime: .eternal), for: key) #expect(mockQueues[key]?.count == 1) @@ -393,7 +393,7 @@ struct HTTPMockTests { @Test func delivery_immediate_returnsQuickly() async throws { - let key = createMockKey(path: "/delay-immediate") + let key = makeKey(path: "/delay-immediate") httpMock.addResponse(.plaintext("ok", delivery: .instant), for: key) let url = try #require(URL(string: "https://example.com/delay-immediate")) @@ -410,7 +410,7 @@ struct HTTPMockTests { @Test func delivery_delayed_respectsInterval() async throws { - let key = createMockKey(path: "/delay-300ms") + let key = makeKey(path: "/delay-300ms") httpMock.addResponse(.plaintext("slow", delivery: .delayed(0.3)), for: key) let url = try #require(URL(string: "https://example.com/delay-300ms")) @@ -427,7 +427,7 @@ struct HTTPMockTests { @Test func delivery_appliesPerResponse_inFifoOrder() async throws { - let key = createMockKey(path: "/delay-sequence") + let key = makeKey(path: "/delay-sequence") httpMock.addResponse(.plaintext("requested-first-but-delivered-second", delivery: .delayed(0.5)), for: key) httpMock.addResponse(.plaintext("requested-second-but-delivered-first", delivery: .instant), for: key) @@ -546,4 +546,18 @@ struct HTTPMockTests { try contents.write(to: url, options: .atomic) return url } + + private func makeKey( + host: String = "example.com", + path: String = "/", + queryItems: [String : String]? = nil, + queryMatching: QueryMatching = .exact + ) -> HTTPMockURLProtocol.Key { + HTTPMockURLProtocol.Key( + host: host, + path: path, + queryItems: queryItems, + queryMatching: queryMatching + ) + } } diff --git a/Tests/HTTPMockTests/Helpers/UtilMethods.swift b/Tests/HTTPMockTests/Helpers/UtilMethods.swift deleted file mode 100644 index 9518212..0000000 --- a/Tests/HTTPMockTests/Helpers/UtilMethods.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation -@testable import HTTPMock - -func createMockKey( - host: String = "example.com", - path: String = "/", - queryItems: [String : String]? = nil, - queryMatching: QueryMatching = .exact -) -> HTTPMockURLProtocol.Key { - .init(host: host, path: path, queryItems: queryItems, queryMatching: queryMatching) -} From c4afe4965b837efffcf223edf4fd3f79ba73e190 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 12:32:12 +0200 Subject: [PATCH 6/9] HTTPMockURLProtocol: Add and update some logging --- .../Internal/HTTPMockURLProtocol.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 3ba354b..9dd3da1 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -134,7 +134,7 @@ final class HTTPMockURLProtocol: URLProtocol { let queryDict = components.queryItems.toDictionary let requestDescription = Self.requestDescription(host: host, path: path, query: queryDict) - HTTPMockLog.trace("Handling request → \(requestDescription)") + HTTPMockLog.trace("Handling incoming request → '\(requestDescription)'") // Look for, and pop, the next queued response mathing host, path and query params. let match = Self.findAndPopNextMock( @@ -147,12 +147,15 @@ final class HTTPMockURLProtocol: URLProtocol { if let match { let key = match.key let mock = match.response + let keyDescription = Self.keyDescription(key) + + HTTPMockLog.trace("Found mock in queue for matching registration: '\(keyDescription)'") let sendResponse = { [weak self] in guard let self else { return } do { - HTTPMockLog.info("Serving mock for \(host)\(path) (\(self.statusCode(of: mock)))") - HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(for: mockIdentifier, key: key))") + HTTPMockLog.info("Serving mock for incoming request \(host)\(path) (\(self.statusCode(of: mock)))") + HTTPMockLog.debug("Remaining queue count for '\(keyDescription)': \(Self.queueSize(for: mockIdentifier, key: key))") let response = HTTPURLResponse( url: url, @@ -166,6 +169,7 @@ final class HTTPMockURLProtocol: URLProtocol { self.client?.urlProtocol(self, didLoad: payload) self.client?.urlProtocolDidFinishLoading(self) } catch { + HTTPMockLog.error("Failed to serve mock for \(host)\(path): \(error)") self.client?.urlProtocol(self, didFailWithError: error) } } @@ -174,13 +178,13 @@ final class HTTPMockURLProtocol: URLProtocol { case .instant: sendResponse() case .delayed(let delay): - HTTPMockLog.info("Delaying response for \(requestDescription) for \(delay) seconds") + HTTPMockLog.info("Delaying response for request '\(requestDescription)' for \(delay) seconds") DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay, execute: sendResponse) } } else { switch Self.getUnmockedPolicy(for: mockIdentifier) { case .notFound: - HTTPMockLog.error("No mock found for \(requestDescription) — returning 404") + HTTPMockLog.error("No mock found for request '\(requestDescription)' — returning 404") let resp = HTTPURLResponse( url: url, statusCode: 404, @@ -283,6 +287,10 @@ extension HTTPMockURLProtocol { mock.status.code } + private static func keyDescription(_ key: Key) -> String { + "\(key.host)\(key.path) \(describeQuery(key.queryItems, key.queryMatching))" + } + private static func requestDescription(host: String, path: String, query: [String: String]) -> String { "\(host)\(path) \(describeQuery(query, nil, dropQueryMatching: true))" } From 6791aa77f1d1b1aef3b79900ac8a56c5ca8240b6 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 12:32:24 +0200 Subject: [PATCH 7/9] HTTPMockMatcher: Log warning if regex compilation fails --- Sources/HTTPMock/Internal/HTTPMockMatcher.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/HTTPMock/Internal/HTTPMockMatcher.swift b/Sources/HTTPMock/Internal/HTTPMockMatcher.swift index eaf0e7e..d84f5c7 100644 --- a/Sources/HTTPMock/Internal/HTTPMockMatcher.swift +++ b/Sources/HTTPMock/Internal/HTTPMockMatcher.swift @@ -87,14 +87,17 @@ struct HTTPMockMatcher { return pattern == value } - // Check if we have a cached pattern already, or create one if not. - // If regex compilation fails: fail silently and return `false`. - guard let regularExpression = try? expressionStorage.regex(for: pattern, kind: kind) else { + do { + // Check if we have a cached pattern already, or create one if not. + let regularExpression = try expressionStorage.regex(for: pattern, kind: kind) + + let searchRange = NSRange(value.startIndex.. Date: Sat, 13 Sep 2025 12:38:18 +0200 Subject: [PATCH 8/9] More log fixes --- .../HTTPMock/Internal/HTTPMockURLProtocol.swift | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 9dd3da1..224bb1d 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -85,14 +85,14 @@ final class HTTPMockURLProtocol: URLProtocol { // Let user know if they're trying to insert responses after an eternal mock. if queue.contains(where: \.isEternal) { - HTTPMockLog.warning("Registering response(s) after an eternal mock for \(mockKeyDescription(key)). These responses will never be served.") + HTTPMockLog.warning("Registered response(s) after an eternal mock for \(keyDescription(key)). These responses will never be served.") } queue.append(contentsOf: responses) mockQueue[key] = queue setQueue(for: mockIdentifier, mockQueue) - HTTPMockLog.info("Registered \(responses.count) response(s) for \(mockKeyDescription(key))") + HTTPMockLog.info("Registered \(responses.count) response(s) for \(keyDescription(key))") HTTPMockLog.debug("Current queue size for \(key.host)\(key.path): \(queue.count)") } @@ -150,12 +150,12 @@ final class HTTPMockURLProtocol: URLProtocol { let keyDescription = Self.keyDescription(key) HTTPMockLog.trace("Found mock in queue for matching registration: '\(keyDescription)'") + HTTPMockLog.debug("Remaining queue count for '\(keyDescription)': \(Self.queueSize(for: mockIdentifier, key: key))") let sendResponse = { [weak self] in guard let self else { return } do { HTTPMockLog.info("Serving mock for incoming request \(host)\(path) (\(self.statusCode(of: mock)))") - HTTPMockLog.debug("Remaining queue count for '\(keyDescription)': \(Self.queueSize(for: mockIdentifier, key: key))") let response = HTTPURLResponse( url: url, @@ -263,7 +263,7 @@ final class HTTPMockURLProtocol: URLProtocol { let copy = first.copyWithNewLifetime(.multiple(count - 1)) mockQueues[matchingKey] = [copy] + queue setQueue(for: mockIdentifier, mockQueues) - HTTPMockLog.info("Mock response will be used \(count) more time(s) for \(mockKeyDescription(matchingKey))") + HTTPMockLog.info("Mock response will be used \(count) more time(s) for \(keyDescription(matchingKey))") return MockMatch(key: matchingKey, response: copy) } case .eternal: @@ -271,7 +271,7 @@ final class HTTPMockURLProtocol: URLProtocol { } if queue.isEmpty { - HTTPMockLog.info("Queue now depleted for \(mockKeyDescription(matchingKey))") + HTTPMockLog.info("Queue now depleted for \(keyDescription(matchingKey))") } return MockMatch(key: matchingKey, response: first) @@ -295,10 +295,6 @@ extension HTTPMockURLProtocol { "\(host)\(path) \(describeQuery(query, nil, dropQueryMatching: true))" } - private static func mockKeyDescription(_ key: Key) -> String { - "\(key.host)\(key.path) \(describeQuery(key.queryItems, key.queryMatching))" - } - private static func queueSize(for mockIdentifier: UUID, key: Key) -> Int { let queue = getQueue(for: mockIdentifier) return queue[key]?.count ?? 0 From 8d0057821459b3dd97b0c19cc59a653617b9aec2 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 12:38:32 +0200 Subject: [PATCH 9/9] Use more explicit variable names --- .../HTTPMock/Internal/HTTPMockURLProtocol.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 224bb1d..d5d20e3 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -185,23 +185,26 @@ final class HTTPMockURLProtocol: URLProtocol { switch Self.getUnmockedPolicy(for: mockIdentifier) { case .notFound: HTTPMockLog.error("No mock found for request '\(requestDescription)' — returning 404") - let resp = HTTPURLResponse( + let response = HTTPURLResponse( url: url, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "text/plain"] )! - client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data("No mock for \(host)\(path)".utf8)) client?.urlProtocolDidFinishLoading(self) case .passthrough: HTTPMockLog.info("No mock found for \(requestDescription) — passthrough to network") - var req = request - let mutableReq = (req as NSURLRequest).mutableCopy() as! NSMutableURLRequest - URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableReq) // prevent loop - req = mutableReq as URLRequest - let task = passthroughSession.dataTask(with: req) { data, response, error in + var request = request + + // Set known value on request to prevent handling the same request multiple times. + let mutableRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest + URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest) + request = mutableRequest as URLRequest + + let task = passthroughSession.dataTask(with: request) { data, response, error in if let error { self.client?.urlProtocol(self, didFailWithError: error) return