Skip to content

Commit 6f8fece

Browse files
BrentMifsudclaude
andcommitted
Fix URLCache query parameter differentiation on Linux
URLCache in swift-corelibs-foundation does not differentiate cache entries by URL query parameters. Work around this by creating synthetic cache key URLs that encode the full URL (including query string) into the path component, ensuring unique cache entries on all platforms. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3bbee8a commit 6f8fece

File tree

5 files changed

+37
-13
lines changed

5 files changed

+37
-13
lines changed

Sources/Simplicity/Implementation/CacheMiddleware.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public actor CacheMiddleware: Middleware {
6767
next: nonisolated(nonsending) @Sendable (MiddlewareRequest) async throws -> MiddlewareResponse
6868
) async throws -> MiddlewareResponse {
6969
let url = request.url
70-
let urlRequest = URLRequest(url: url)
70+
let urlRequest = cacheKeyRequest(for: url)
7171

7272
// Handle cache policy
7373
switch request.cachePolicy {
@@ -120,7 +120,7 @@ public actor CacheMiddleware: Middleware {
120120
status: HTTPResponse.Status = .ok,
121121
headerFields: HTTPFields = HTTPFields()
122122
) {
123-
let urlRequest = URLRequest(url: url)
123+
let urlRequest = cacheKeyRequest(for: url)
124124

125125
var headerDict: [String: String] = [:]
126126
for field in headerFields {
@@ -145,7 +145,7 @@ public actor CacheMiddleware: Middleware {
145145
///
146146
/// - Parameter url: The URL whose cached response should be removed.
147147
public func removeCached(for url: URL) {
148-
let urlRequest = URLRequest(url: url)
148+
let urlRequest = cacheKeyRequest(for: url)
149149
urlCache.removeCachedResponse(for: urlRequest)
150150
}
151151

@@ -159,7 +159,7 @@ public actor CacheMiddleware: Middleware {
159159
/// - Parameter url: The URL to check.
160160
/// - Returns: `true` if a cached response exists, `false` otherwise.
161161
public func hasCachedResponse(for url: URL) -> Bool {
162-
let urlRequest = URLRequest(url: url)
162+
let urlRequest = cacheKeyRequest(for: url)
163163
return urlCache.cachedResponse(for: urlRequest) != nil
164164
}
165165

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// URLCacheCompat.swift
3+
// Simplicity
4+
//
5+
6+
import Foundation
7+
#if canImport(FoundationNetworking)
8+
import FoundationNetworking
9+
#endif
10+
11+
/// Creates a `URLRequest` to use as a `URLCache` key for the given URL.
12+
///
13+
/// On Linux, `URLCache` in swift-corelibs-foundation does not differentiate cache entries
14+
/// by URL query parameters. This function works around the limitation by encoding the full
15+
/// URL (including query string) into a synthetic path component, ensuring unique cache keys.
16+
///
17+
/// On Apple platforms, this simply returns `URLRequest(url:)`.
18+
func cacheKeyRequest(for url: URL) -> URLRequest {
19+
#if canImport(FoundationNetworking)
20+
// Encode the entire URL (scheme, host, path, query, fragment) into a single
21+
// percent-encoded path segment. The resulting synthetic URL has no query component,
22+
// so URLCache cannot collapse entries that differ only by query parameters.
23+
let encoded = url.absoluteString.addingPercentEncoding(
24+
withAllowedCharacters: .alphanumerics
25+
) ?? url.absoluteString
26+
return URLRequest(url: URL(string: "simplicity-cache://entry/\(encoded)")!)
27+
#else
28+
return URLRequest(url: url)
29+
#endif
30+
}

Sources/Simplicity/Implementation/URLSessionClient.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ public actor URLSessionClient: Client {
349349
) async throws(ClientError) where R.SuccessResponseBody: Encodable {
350350
let cache = urlCache ?? .shared
351351
let url = request.requestURL(baseURL: baseURL)
352-
let urlRequest = URLRequest(url: url)
352+
let urlRequest = cacheKeyRequest(for: url)
353353

354354
let data: Data
355355
do {
@@ -386,7 +386,7 @@ public actor URLSessionClient: Client {
386386
) async throws(ClientError) -> Response<R.SuccessResponseBody, R.FailureResponseBody> {
387387
let cache = urlCache ?? .shared
388388
let url = request.requestURL(baseURL: baseURL)
389-
let urlRequest = URLRequest(url: url)
389+
let urlRequest = cacheKeyRequest(for: url)
390390

391391
guard let cachedResponse = cache.cachedResponse(for: urlRequest),
392392
let httpURLResponse = cachedResponse.response as? HTTPURLResponse,
@@ -407,7 +407,7 @@ public actor URLSessionClient: Client {
407407
) async {
408408
let cache = urlCache ?? .shared
409409
let url = request.requestURL(baseURL: baseURL)
410-
let urlRequest = URLRequest(url: url)
410+
let urlRequest = cacheKeyRequest(for: url)
411411
cache.removeCachedResponse(for: urlRequest)
412412
}
413413
}

Tests/SimplicityTests/CacheMiddlewareTests.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,6 @@ struct CacheMiddlewareTests {
261261
#expect(hasCached)
262262
}
263263

264-
// URLCache in swift-corelibs-foundation does not differentiate cache entries by query parameters.
265-
#if !canImport(FoundationNetworking)
266264
@Test
267265
func testDifferentQueryParams_differentCacheEntries() async throws {
268266
// Arrange
@@ -330,7 +328,6 @@ struct CacheMiddlewareTests {
330328
#expect(cached1.body == data1)
331329
#expect(cached2.body == data2)
332330
}
333-
#endif
334331

335332
@Test
336333
func testRemoveCached_invalidatesEntry() async throws {

Tests/SimplicityTests/URLSessionHTTPClientTests.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -535,8 +535,6 @@ struct URLSessionClientCacheTests {
535535
#expect(cached.headerFields[HTTPField.Name("X-Custom")!] == "value")
536536
}
537537

538-
// URLCache in swift-corelibs-foundation does not differentiate cache entries by query parameters.
539-
#if !canImport(FoundationNetworking)
540538
@Test
541539
func testCachedResponse_differentQueryParams_differentCacheEntries() async throws {
542540
// Arrange
@@ -555,7 +553,6 @@ struct URLSessionClientCacheTests {
555553
#expect(try cached1.decodeSuccessBody() == model1)
556554
#expect(try cached2.decodeSuccessBody() == model2)
557555
}
558-
#endif
559556

560557
@Test
561558
func testClearNetworkCache_clearsAllCachedResponses() async throws {

0 commit comments

Comments
 (0)