Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,19 @@ jobs:

- name: Run tests
run: ${{ matrix.command }}

test-linux:
runs-on: ubuntu-latest
container: swift:6.2
name: Test (Linux)
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}

- name: Display Swift version
run: swift --version

- name: Run tests
run: swift test
6 changes: 5 additions & 1 deletion Sources/Simplicity/Extension/CachePolicy+URLRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
// Created by Brent Mifsud on 2025-10-10.
//

#if canImport(FoundationNetworking)
import Foundation
public import FoundationNetworking
#else
public import Foundation
#endif

public extension CachePolicy {
@inlinable
var urlRequestCachePolicy: URLRequest.CachePolicy {
switch self {
case .useProtocolCachePolicy:
Expand Down
11 changes: 7 additions & 4 deletions Sources/Simplicity/Implementation/CacheMiddleware.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
public import Foundation
public import HTTPTypes
import HTTPTypesFoundation
#if canImport(FoundationNetworking)
public import FoundationNetworking
#endif

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

// Handle cache policy
switch request.cachePolicy {
Expand Down Expand Up @@ -117,7 +120,7 @@ public actor CacheMiddleware: Middleware {
status: HTTPResponse.Status = .ok,
headerFields: HTTPFields = HTTPFields()
) {
let urlRequest = URLRequest(url: url)
let urlRequest = cacheKeyRequest(for: url)

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

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

Expand Down
3 changes: 3 additions & 0 deletions Sources/Simplicity/Implementation/ClientError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
//

public import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public nonisolated enum ClientError: Sendable, LocalizedError {
case cancelled
Expand Down
30 changes: 30 additions & 0 deletions Sources/Simplicity/Implementation/URLCacheCompat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// URLCacheCompat.swift
// Simplicity
//

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// Creates a `URLRequest` to use as a `URLCache` key for the given URL.
///
/// On Linux, `URLCache` in swift-corelibs-foundation does not differentiate cache entries
/// by URL query parameters. This function works around the limitation by encoding the full
/// URL (including query string) into a synthetic path component, ensuring unique cache keys.
///
/// On Apple platforms, this simply returns `URLRequest(url:)`.
func cacheKeyRequest(for url: URL) -> URLRequest {
#if canImport(FoundationNetworking)
// Encode the entire URL (scheme, host, path, query, fragment) into a single
// percent-encoded path segment. The resulting synthetic URL has no query component,
// so URLCache cannot collapse entries that differ only by query parameters.
let encoded = url.absoluteString.addingPercentEncoding(
withAllowedCharacters: .alphanumerics
) ?? url.absoluteString
return URLRequest(url: URL(string: "simplicity-cache://entry/\(encoded)")!)
#else
return URLRequest(url: url)
#endif
}
17 changes: 14 additions & 3 deletions Sources/Simplicity/Implementation/URLSessionClient.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
public import Foundation
public import HTTPTypes
import HTTPTypesFoundation
#if canImport(FoundationNetworking)
public import FoundationNetworking
#endif

/// A concrete HTTP client that uses `URLSession` to send requests and receive responses,
/// with support for a configurable base URL and a chain of middlewares.
Expand Down Expand Up @@ -231,7 +234,11 @@ public actor URLSessionClient: Client {
} catch let error as URLError {
throw .transport(error)
} catch let error as NSError where error.domain == NSURLErrorDomain {
#if canImport(FoundationNetworking)
let urlError = URLError(URLError.Code(rawValue: error.code)!, userInfo: error.userInfo)
#else
let urlError = URLError(URLError.Code(rawValue: error.code), userInfo: error.userInfo)
#endif
if urlError.code == .cancelled {
throw .cancelled
} else if urlError.code == .timedOut {
Expand Down Expand Up @@ -296,7 +303,11 @@ public actor URLSessionClient: Client {
} catch let error as URLError {
throw .transport(error)
} catch let error as NSError where error.domain == NSURLErrorDomain {
#if canImport(FoundationNetworking)
let urlError = URLError(URLError.Code(rawValue: error.code)!, userInfo: error.userInfo)
#else
let urlError = URLError(URLError.Code(rawValue: error.code), userInfo: error.userInfo)
#endif
if urlError.code == .cancelled {
throw .cancelled
} else if urlError.code == .timedOut {
Expand Down Expand Up @@ -338,7 +349,7 @@ public actor URLSessionClient: Client {
) async throws(ClientError) where R.SuccessResponseBody: Encodable {
let cache = urlCache ?? .shared
let url = request.requestURL(baseURL: baseURL)
let urlRequest = URLRequest(url: url)
let urlRequest = cacheKeyRequest(for: url)

let data: Data
do {
Expand Down Expand Up @@ -375,7 +386,7 @@ public actor URLSessionClient: Client {
) async throws(ClientError) -> Response<R.SuccessResponseBody, R.FailureResponseBody> {
let cache = urlCache ?? .shared
let url = request.requestURL(baseURL: baseURL)
let urlRequest = URLRequest(url: url)
let urlRequest = cacheKeyRequest(for: url)

guard let cachedResponse = cache.cachedResponse(for: urlRequest),
let httpURLResponse = cachedResponse.response as? HTTPURLResponse,
Expand All @@ -396,7 +407,7 @@ public actor URLSessionClient: Client {
) async {
let cache = urlCache ?? .shared
let url = request.requestURL(baseURL: baseURL)
let urlRequest = URLRequest(url: url)
let urlRequest = cacheKeyRequest(for: url)
cache.removeCachedResponse(for: urlRequest)
}
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/Simplicity/Implementation/URLSessionTransport.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
public import Foundation
public import HTTPTypes
import HTTPTypesFoundation
#if canImport(FoundationNetworking)
public import FoundationNetworking
#endif

/// A ``Transport`` backed by a real `URLSession`.
///
Expand Down
4 changes: 3 additions & 1 deletion Sources/Simplicity/Protocol/Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ public extension Request {
return url
}

return url.appending(queryItems: queryItems)
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.queryItems = queryItems
return components.url!
}
}

Expand Down
50 changes: 33 additions & 17 deletions Tests/SimplicityTests/CacheMiddlewareTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Testing
import HTTPTypes
@testable import Simplicity
Expand All @@ -10,7 +13,7 @@ struct CacheMiddlewareTests {
@Test
func testReturnCacheDataElseLoad_returnsCachedResponse_whenAvailable() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url = baseURL.appending(path: "/test")
let cachedData = Data("cached".utf8)
Expand Down Expand Up @@ -43,7 +46,7 @@ struct CacheMiddlewareTests {
@Test
func testReturnCacheDataElseLoad_callsNetwork_whenNotCached() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url = baseURL.appending(path: "/test")
let networkData = Data("network".utf8)
Expand Down Expand Up @@ -72,7 +75,7 @@ struct CacheMiddlewareTests {
@Test
func testReturnCacheDataDontLoad_throwsCacheMiss_whenNotCached() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url = baseURL.appending(path: "/test")

Expand Down Expand Up @@ -106,7 +109,7 @@ struct CacheMiddlewareTests {
@Test
func testReturnCacheDataDontLoad_returnsCached_whenAvailable() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url = baseURL.appending(path: "/test")
let cachedData = Data("cached".utf8)
Expand Down Expand Up @@ -138,7 +141,7 @@ struct CacheMiddlewareTests {
@Test
func testReloadIgnoringLocalCacheData_alwaysCallsNetwork() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url = baseURL.appending(path: "/test")
let cachedData = Data("cached".utf8)
Expand Down Expand Up @@ -171,7 +174,7 @@ struct CacheMiddlewareTests {
@Test
func testCachesSuccessResponsesByDefault() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url = baseURL.appending(path: "/test")
let networkData = Data("network".utf8)
Expand Down Expand Up @@ -201,7 +204,7 @@ struct CacheMiddlewareTests {
@Test
func testDoesNotCacheFailureResponsesByDefault() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url = baseURL.appending(path: "/test")
let errorData = Data("error".utf8)
Expand Down Expand Up @@ -231,7 +234,7 @@ struct CacheMiddlewareTests {
@Test
func testCustomShouldCacheResponse_allowsCachingFailures() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache) { _ in true } // Cache everything
let url = baseURL.appending(path: "/test")
let errorData = Data("error".utf8)
Expand Down Expand Up @@ -261,7 +264,7 @@ struct CacheMiddlewareTests {
@Test
func testDifferentQueryParams_differentCacheEntries() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let data1 = Data("data1".utf8)
let data2 = Data("data2".utf8)
Expand Down Expand Up @@ -300,8 +303,8 @@ struct CacheMiddlewareTests {
#expect(response2.body == data2)

// Verify both are now cached separately
let url1 = baseURL.appending(path: "/test").appending(queryItems: [URLQueryItem(name: "filter", value: "a")])
let url2 = baseURL.appending(path: "/test").appending(queryItems: [URLQueryItem(name: "filter", value: "b")])
let url1 = makeURL(base: baseURL, path: "/test", queryItems: [URLQueryItem(name: "filter", value: "a")])
let url2 = makeURL(base: baseURL, path: "/test", queryItems: [URLQueryItem(name: "filter", value: "b")])
#expect(await middleware.hasCachedResponse(for: url1))
#expect(await middleware.hasCachedResponse(for: url2))

Expand Down Expand Up @@ -329,7 +332,7 @@ struct CacheMiddlewareTests {
@Test
func testRemoveCached_invalidatesEntry() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url = baseURL.appending(path: "/test")
let cachedData = Data("cached".utf8)
Expand All @@ -347,7 +350,7 @@ struct CacheMiddlewareTests {
@Test
func testClearCache_removesAllEntries() async throws {
// Arrange
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
let cache = makeTestCache()
let middleware = CacheMiddleware(urlCache: cache)
let url1 = baseURL.appending(path: "/test1")
let url2 = baseURL.appending(path: "/test2")
Expand All @@ -369,17 +372,22 @@ struct CacheMiddlewareTests {

// MARK: - Test Helpers

private func makeTestCache() -> URLCache {
#if canImport(FoundationNetworking)
URLCache(memoryCapacity: 10_000_000, diskCapacity: 0, diskPath: nil)
#else
URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
#endif
}

/// Constructs a `MiddlewareRequest` for testing purposes.
private func makeMiddlewareRequest(
baseURL: URL,
path: String,
queryItems: [URLQueryItem] = [],
cachePolicy: CachePolicy
) -> MiddlewareRequest {
var url = baseURL.appending(path: path)
if !queryItems.isEmpty {
url = url.appending(queryItems: queryItems)
}
let url = makeURL(base: baseURL, path: path, queryItems: queryItems)
let httpRequest = HTTPRequest(method: .get, url: url, headerFields: HTTPFields())
return MiddlewareRequest(
httpRequest: httpRequest,
Expand All @@ -389,3 +397,11 @@ private func makeMiddlewareRequest(
cachePolicy: cachePolicy
)
}

private func makeURL(base: URL, path: String, queryItems: [URLQueryItem] = []) -> URL {
let url = base.appending(path: path)
guard !queryItems.isEmpty else { return url }
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.queryItems = queryItems
return components.url!
}
1 change: 0 additions & 1 deletion Tests/SimplicityTests/MiddlewareTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import Simplicity

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

Expand Down
12 changes: 10 additions & 2 deletions Tests/SimplicityTests/URLSessionHTTPClientTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Testing
import HTTPTypes
@testable import Simplicity
Expand Down Expand Up @@ -577,8 +580,13 @@ struct URLSessionClientCacheTests {
// MARK: - Cache Test Helpers

private func makeCacheableClient(baseURL: URL) -> URLSessionClient {
URLSessionClient(
urlCache: URLCache(memoryCapacity: 10_000_000, diskCapacity: 0),
#if canImport(FoundationNetworking)
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0, diskPath: nil)
#else
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 0)
#endif
return URLSessionClient(
urlCache: cache,
baseURL: baseURL,
middlewares: []
)
Expand Down