Skip to content

Commit 0c8af19

Browse files
authored
Merge pull request #8 from BrentMifsud/bm/linux-support
Add Linux support
2 parents 4bca01d + 6f8fece commit 0c8af19

File tree

11 files changed

+124
-29
lines changed

11 files changed

+124
-29
lines changed

.github/workflows/ci.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,19 @@ jobs:
4949

5050
- name: Run tests
5151
run: ${{ matrix.command }}
52+
53+
test-linux:
54+
runs-on: ubuntu-latest
55+
container: swift:6.2
56+
name: Test (Linux)
57+
steps:
58+
- name: Checkout repository
59+
uses: actions/checkout@v4
60+
with:
61+
ref: ${{ github.event.inputs.branch }}
62+
63+
- name: Display Swift version
64+
run: swift --version
65+
66+
- name: Run tests
67+
run: swift test

Sources/Simplicity/Extension/CachePolicy+URLRequest.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
// Created by Brent Mifsud on 2025-10-10.
66
//
77

8+
#if canImport(FoundationNetworking)
9+
import Foundation
10+
public import FoundationNetworking
11+
#else
812
public import Foundation
13+
#endif
914

1015
public extension CachePolicy {
11-
@inlinable
1216
var urlRequestCachePolicy: URLRequest.CachePolicy {
1317
switch self {
1418
case .useProtocolCachePolicy:

Sources/Simplicity/Implementation/CacheMiddleware.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
public import Foundation
99
public import HTTPTypes
1010
import HTTPTypesFoundation
11+
#if canImport(FoundationNetworking)
12+
public import FoundationNetworking
13+
#endif
1114

1215
/// A middleware that provides caching for HTTP responses using `URLCache`.
1316
///
@@ -64,7 +67,7 @@ public actor CacheMiddleware: Middleware {
6467
next: nonisolated(nonsending) @Sendable (MiddlewareRequest) async throws -> MiddlewareResponse
6568
) async throws -> MiddlewareResponse {
6669
let url = request.url
67-
let urlRequest = URLRequest(url: url)
70+
let urlRequest = cacheKeyRequest(for: url)
6871

6972
// Handle cache policy
7073
switch request.cachePolicy {
@@ -117,7 +120,7 @@ public actor CacheMiddleware: Middleware {
117120
status: HTTPResponse.Status = .ok,
118121
headerFields: HTTPFields = HTTPFields()
119122
) {
120-
let urlRequest = URLRequest(url: url)
123+
let urlRequest = cacheKeyRequest(for: url)
121124

122125
var headerDict: [String: String] = [:]
123126
for field in headerFields {
@@ -142,7 +145,7 @@ public actor CacheMiddleware: Middleware {
142145
///
143146
/// - Parameter url: The URL whose cached response should be removed.
144147
public func removeCached(for url: URL) {
145-
let urlRequest = URLRequest(url: url)
148+
let urlRequest = cacheKeyRequest(for: url)
146149
urlCache.removeCachedResponse(for: urlRequest)
147150
}
148151

@@ -156,7 +159,7 @@ public actor CacheMiddleware: Middleware {
156159
/// - Parameter url: The URL to check.
157160
/// - Returns: `true` if a cached response exists, `false` otherwise.
158161
public func hasCachedResponse(for url: URL) -> Bool {
159-
let urlRequest = URLRequest(url: url)
162+
let urlRequest = cacheKeyRequest(for: url)
160163
return urlCache.cachedResponse(for: urlRequest) != nil
161164
}
162165

Sources/Simplicity/Implementation/ClientError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
//
77

88
public import Foundation
9+
#if canImport(FoundationNetworking)
10+
import FoundationNetworking
11+
#endif
912

1013
public nonisolated enum ClientError: Sendable, LocalizedError {
1114
case cancelled
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: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
public import Foundation
22
public import HTTPTypes
33
import HTTPTypesFoundation
4+
#if canImport(FoundationNetworking)
5+
public import FoundationNetworking
6+
#endif
47

58
/// A concrete HTTP client that uses `URLSession` to send requests and receive responses,
69
/// with support for a configurable base URL and a chain of middlewares.
@@ -231,7 +234,11 @@ public actor URLSessionClient: Client {
231234
} catch let error as URLError {
232235
throw .transport(error)
233236
} catch let error as NSError where error.domain == NSURLErrorDomain {
237+
#if canImport(FoundationNetworking)
238+
let urlError = URLError(URLError.Code(rawValue: error.code)!, userInfo: error.userInfo)
239+
#else
234240
let urlError = URLError(URLError.Code(rawValue: error.code), userInfo: error.userInfo)
241+
#endif
235242
if urlError.code == .cancelled {
236243
throw .cancelled
237244
} else if urlError.code == .timedOut {
@@ -296,7 +303,11 @@ public actor URLSessionClient: Client {
296303
} catch let error as URLError {
297304
throw .transport(error)
298305
} catch let error as NSError where error.domain == NSURLErrorDomain {
306+
#if canImport(FoundationNetworking)
307+
let urlError = URLError(URLError.Code(rawValue: error.code)!, userInfo: error.userInfo)
308+
#else
299309
let urlError = URLError(URLError.Code(rawValue: error.code), userInfo: error.userInfo)
310+
#endif
300311
if urlError.code == .cancelled {
301312
throw .cancelled
302313
} else if urlError.code == .timedOut {
@@ -338,7 +349,7 @@ public actor URLSessionClient: Client {
338349
) async throws(ClientError) where R.SuccessResponseBody: Encodable {
339350
let cache = urlCache ?? .shared
340351
let url = request.requestURL(baseURL: baseURL)
341-
let urlRequest = URLRequest(url: url)
352+
let urlRequest = cacheKeyRequest(for: url)
342353

343354
let data: Data
344355
do {
@@ -375,7 +386,7 @@ public actor URLSessionClient: Client {
375386
) async throws(ClientError) -> Response<R.SuccessResponseBody, R.FailureResponseBody> {
376387
let cache = urlCache ?? .shared
377388
let url = request.requestURL(baseURL: baseURL)
378-
let urlRequest = URLRequest(url: url)
389+
let urlRequest = cacheKeyRequest(for: url)
379390

380391
guard let cachedResponse = cache.cachedResponse(for: urlRequest),
381392
let httpURLResponse = cachedResponse.response as? HTTPURLResponse,
@@ -396,7 +407,7 @@ public actor URLSessionClient: Client {
396407
) async {
397408
let cache = urlCache ?? .shared
398409
let url = request.requestURL(baseURL: baseURL)
399-
let urlRequest = URLRequest(url: url)
410+
let urlRequest = cacheKeyRequest(for: url)
400411
cache.removeCachedResponse(for: urlRequest)
401412
}
402413
}

Sources/Simplicity/Implementation/URLSessionTransport.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
public import Foundation
22
public import HTTPTypes
33
import HTTPTypesFoundation
4+
#if canImport(FoundationNetworking)
5+
public import FoundationNetworking
6+
#endif
47

58
/// A ``Transport`` backed by a real `URLSession`.
69
///

Sources/Simplicity/Protocol/Request.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ public extension Request {
103103
return url
104104
}
105105

106-
return url.appending(queryItems: queryItems)
106+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
107+
components.queryItems = queryItems
108+
return components.url!
107109
}
108110
}
109111

Tests/SimplicityTests/CacheMiddlewareTests.swift

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import Foundation
2+
#if canImport(FoundationNetworking)
3+
import FoundationNetworking
4+
#endif
25
import Testing
36
import HTTPTypes
47
@testable import Simplicity
@@ -10,7 +13,7 @@ struct CacheMiddlewareTests {
1013
@Test
1114
func testReturnCacheDataElseLoad_returnsCachedResponse_whenAvailable() async throws {
1215
// Arrange
13-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
16+
let cache = makeTestCache()
1417
let middleware = CacheMiddleware(urlCache: cache)
1518
let url = baseURL.appending(path: "/test")
1619
let cachedData = Data("cached".utf8)
@@ -43,7 +46,7 @@ struct CacheMiddlewareTests {
4346
@Test
4447
func testReturnCacheDataElseLoad_callsNetwork_whenNotCached() async throws {
4548
// Arrange
46-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
49+
let cache = makeTestCache()
4750
let middleware = CacheMiddleware(urlCache: cache)
4851
let url = baseURL.appending(path: "/test")
4952
let networkData = Data("network".utf8)
@@ -72,7 +75,7 @@ struct CacheMiddlewareTests {
7275
@Test
7376
func testReturnCacheDataDontLoad_throwsCacheMiss_whenNotCached() async throws {
7477
// Arrange
75-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
78+
let cache = makeTestCache()
7679
let middleware = CacheMiddleware(urlCache: cache)
7780
let url = baseURL.appending(path: "/test")
7881

@@ -106,7 +109,7 @@ struct CacheMiddlewareTests {
106109
@Test
107110
func testReturnCacheDataDontLoad_returnsCached_whenAvailable() async throws {
108111
// Arrange
109-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
112+
let cache = makeTestCache()
110113
let middleware = CacheMiddleware(urlCache: cache)
111114
let url = baseURL.appending(path: "/test")
112115
let cachedData = Data("cached".utf8)
@@ -138,7 +141,7 @@ struct CacheMiddlewareTests {
138141
@Test
139142
func testReloadIgnoringLocalCacheData_alwaysCallsNetwork() async throws {
140143
// Arrange
141-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
144+
let cache = makeTestCache()
142145
let middleware = CacheMiddleware(urlCache: cache)
143146
let url = baseURL.appending(path: "/test")
144147
let cachedData = Data("cached".utf8)
@@ -171,7 +174,7 @@ struct CacheMiddlewareTests {
171174
@Test
172175
func testCachesSuccessResponsesByDefault() async throws {
173176
// Arrange
174-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
177+
let cache = makeTestCache()
175178
let middleware = CacheMiddleware(urlCache: cache)
176179
let url = baseURL.appending(path: "/test")
177180
let networkData = Data("network".utf8)
@@ -201,7 +204,7 @@ struct CacheMiddlewareTests {
201204
@Test
202205
func testDoesNotCacheFailureResponsesByDefault() async throws {
203206
// Arrange
204-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
207+
let cache = makeTestCache()
205208
let middleware = CacheMiddleware(urlCache: cache)
206209
let url = baseURL.appending(path: "/test")
207210
let errorData = Data("error".utf8)
@@ -231,7 +234,7 @@ struct CacheMiddlewareTests {
231234
@Test
232235
func testCustomShouldCacheResponse_allowsCachingFailures() async throws {
233236
// Arrange
234-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
237+
let cache = makeTestCache()
235238
let middleware = CacheMiddleware(urlCache: cache) { _ in true } // Cache everything
236239
let url = baseURL.appending(path: "/test")
237240
let errorData = Data("error".utf8)
@@ -261,7 +264,7 @@ struct CacheMiddlewareTests {
261264
@Test
262265
func testDifferentQueryParams_differentCacheEntries() async throws {
263266
// Arrange
264-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
267+
let cache = makeTestCache()
265268
let middleware = CacheMiddleware(urlCache: cache)
266269
let data1 = Data("data1".utf8)
267270
let data2 = Data("data2".utf8)
@@ -300,8 +303,8 @@ struct CacheMiddlewareTests {
300303
#expect(response2.body == data2)
301304

302305
// Verify both are now cached separately
303-
let url1 = baseURL.appending(path: "/test").appending(queryItems: [URLQueryItem(name: "filter", value: "a")])
304-
let url2 = baseURL.appending(path: "/test").appending(queryItems: [URLQueryItem(name: "filter", value: "b")])
306+
let url1 = makeURL(base: baseURL, path: "/test", queryItems: [URLQueryItem(name: "filter", value: "a")])
307+
let url2 = makeURL(base: baseURL, path: "/test", queryItems: [URLQueryItem(name: "filter", value: "b")])
305308
#expect(await middleware.hasCachedResponse(for: url1))
306309
#expect(await middleware.hasCachedResponse(for: url2))
307310

@@ -329,7 +332,7 @@ struct CacheMiddlewareTests {
329332
@Test
330333
func testRemoveCached_invalidatesEntry() async throws {
331334
// Arrange
332-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
335+
let cache = makeTestCache()
333336
let middleware = CacheMiddleware(urlCache: cache)
334337
let url = baseURL.appending(path: "/test")
335338
let cachedData = Data("cached".utf8)
@@ -347,7 +350,7 @@ struct CacheMiddlewareTests {
347350
@Test
348351
func testClearCache_removesAllEntries() async throws {
349352
// Arrange
350-
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
353+
let cache = makeTestCache()
351354
let middleware = CacheMiddleware(urlCache: cache)
352355
let url1 = baseURL.appending(path: "/test1")
353356
let url2 = baseURL.appending(path: "/test2")
@@ -369,17 +372,22 @@ struct CacheMiddlewareTests {
369372

370373
// MARK: - Test Helpers
371374

375+
private func makeTestCache() -> URLCache {
376+
#if canImport(FoundationNetworking)
377+
URLCache(memoryCapacity: 10_000_000, diskCapacity: 0, diskPath: nil)
378+
#else
379+
URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
380+
#endif
381+
}
382+
372383
/// Constructs a `MiddlewareRequest` for testing purposes.
373384
private func makeMiddlewareRequest(
374385
baseURL: URL,
375386
path: String,
376387
queryItems: [URLQueryItem] = [],
377388
cachePolicy: CachePolicy
378389
) -> MiddlewareRequest {
379-
var url = baseURL.appending(path: path)
380-
if !queryItems.isEmpty {
381-
url = url.appending(queryItems: queryItems)
382-
}
390+
let url = makeURL(base: baseURL, path: path, queryItems: queryItems)
383391
let httpRequest = HTTPRequest(method: .get, url: url, headerFields: HTTPFields())
384392
return MiddlewareRequest(
385393
httpRequest: httpRequest,
@@ -389,3 +397,11 @@ private func makeMiddlewareRequest(
389397
cachePolicy: cachePolicy
390398
)
391399
}
400+
401+
private func makeURL(base: URL, path: String, queryItems: [URLQueryItem] = []) -> URL {
402+
let url = base.appending(path: path)
403+
guard !queryItems.isEmpty else { return url }
404+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
405+
components.queryItems = queryItems
406+
return components.url!
407+
}

Tests/SimplicityTests/MiddlewareTests.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import Simplicity
55

66
@Suite("Middleware tests")
77
struct MiddlewareTests {
8-
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
98
struct MockRequest: Request {
109
typealias RequestBody = Never?
1110

0 commit comments

Comments
 (0)