diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5aa42..4edea92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ All notable changes to NetworkingKit are documented in this file. -## 2.2.10 - Unreleased +## 2.3.1 - Unreleased + +### Changed + +- Correct HTTP cache revalidation by merging `304 Not Modified` metadata with cached headers. +- Store independent `Vary` response variants, bypass request `no-store`, honor `no-cache`, and reject `Vary: *` responses. +- Use SHA-256 disk-cache filenames to avoid URL-derived filename length limits. + +## 2.2.10 - 2026-07-18 ### Added diff --git a/README.md b/README.md index 11c51d1..0e0686d 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ let transport = CachingTransport( For cache survival across launches and standards-based revalidation, use `DiskResponseCache`. Expired entries automatically send `If-None-Match` when an ETag is available; a `304 Not Modified` response reuses the local body and refreshes its TTL. -The cache also honors `Cache-Control: no-store`, supports `Expires`, validates `Vary` request headers before reuse, and keeps the in-memory cache in least-recently-used order. +The cache honors request and response `Cache-Control: no-store`, `no-cache`, and `Expires`; it maintains separate response variants for `Vary` headers and never stores `Vary: *` responses. `304 Not Modified` merges its metadata with the cached response before refreshing TTL. The in-memory cache keeps entries in least-recently-used order. ```swift let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 2a5b8c1..44dd880 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -252,7 +252,7 @@ let transport = CachingTransport( 需要跨 App 启动保留缓存并遵循 HTTP 重新验证语义时,请使用 `DiskResponseCache`。过期条目存在 ETag 时会自动发送 `If-None-Match`;服务端返回 `304 Not Modified` 后会复用本地 body 并刷新 TTL。 -缓存同时遵守 `Cache-Control: no-store`、支持 `Expires`,复用前会校验 `Vary` 请求 Header,内存缓存则采用最近最少使用(LRU)淘汰顺序。 +缓存遵守请求和响应中的 `Cache-Control: no-store`、`no-cache`,并支持 `Expires`;会为 `Vary` Header 保留独立响应变体,且永不缓存 `Vary: *` 响应。`304 Not Modified` 会先与旧缓存 Header 合并再刷新 TTL,内存缓存则采用最近最少使用(LRU)淘汰顺序。 ```swift let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] diff --git a/Sources/NetworkingKit/Core/CachingTransport.swift b/Sources/NetworkingKit/Core/CachingTransport.swift index 11bc708..984803b 100644 --- a/Sources/NetworkingKit/Core/CachingTransport.swift +++ b/Sources/NetworkingKit/Core/CachingTransport.swift @@ -7,6 +7,7 @@ // import Foundation +import CryptoKit /// Controls how a `CachingTransport` resolves GET requests. public enum NetworkCachePolicy: Sendable, Equatable { @@ -21,9 +22,18 @@ public enum NetworkCachePolicy: Sendable, Equatable { /// Stores cached transport responses. public protocol NetworkResponseCaching: Sendable { func entry(for key: String) async -> CachedHTTPResponse? + /// Returns every cached response variant for a base request key. + func entries(for key: String) async -> [CachedHTTPResponse] func store(_ entry: CachedHTTPResponse, for key: String) async } +public extension NetworkResponseCaching { + func entries(for key: String) async -> [CachedHTTPResponse] { + guard let entry = await entry(for: key) else { return [] } + return [entry] + } +} + /// A cached HTTP response with its expiry and revalidation metadata. public struct CachedHTTPResponse: Sendable, Codable { public let data: Data @@ -43,31 +53,56 @@ public struct CachedHTTPResponse: Sendable, Codable { func matches(_ request: URLRequest) -> Bool { varyHeaders.allSatisfy { request.value(forHTTPHeaderField: $0.key) == $0.value } } + + func mergingRevalidationHeaders(from response: HTTPURLResponse, defaultTTL: TimeInterval) -> CachedHTTPResponse { + let revalidationHeaders = response.headers + let mergedHeaders = headers.merging(revalidationHeaders) { _, new in new } + return CachedHTTPResponse( + data: data, + url: response.url ?? url, + statusCode: statusCode, + headers: mergedHeaders, + expiresAt: CacheControl.expiry(headers: mergedHeaders, defaultTTL: defaultTTL), + eTag: mergedHeaders.value(forHTTPHeaderField: "ETag"), + varyHeaders: varyHeaders + ) + } } /// A bounded, actor-backed in-memory response cache. public actor InMemoryResponseCache: NetworkResponseCaching { private let capacity: Int - private var values: [String: CachedHTTPResponse] = [:] + private var values: [String: [String: CachedHTTPResponse]] = [:] private var keys: [String] = [] public init(capacity: Int = 100) { self.capacity = max(1, capacity) } public func entry(for key: String) async -> CachedHTTPResponse? { - guard let entry = values[key] else { return nil } - keys.removeAll { $0 == key } - keys.append(key) + guard let variants = values[key], let entry = variants.values.first else { return nil } + touch(key) return entry } + public func entries(for key: String) async -> [CachedHTTPResponse] { + guard let entries = values[key]?.values else { return [] } + touch(key) + return Array(entries) + } + public func store(_ entry: CachedHTTPResponse, for key: String) async { if values[key] == nil, keys.count >= capacity, let oldest = keys.first { values.removeValue(forKey: oldest) keys.removeFirst() } + var variants = values[key] ?? [:] + variants[entry.variantIdentifier] = entry + values[key] = variants + touch(key) + } + + private func touch(_ key: String) { keys.removeAll { $0 == key } keys.append(key) - values[key] = entry } } @@ -81,17 +116,25 @@ public actor DiskResponseCache: NetworkResponseCaching { } public func entry(for key: String) async -> CachedHTTPResponse? { - guard let data = try? Data(contentsOf: fileURL(for: key)) else { return nil } - return try? JSONDecoder().decode(CachedHTTPResponse.self, from: data) + await entries(for: key).last + } + + public func entries(for key: String) async -> [CachedHTTPResponse] { + guard let data = try? Data(contentsOf: fileURL(for: key)) else { return [] } + if let entries = try? JSONDecoder().decode([CachedHTTPResponse].self, from: data) { return entries } + return (try? JSONDecoder().decode(CachedHTTPResponse.self, from: data)).map { [$0] } ?? [] } public func store(_ entry: CachedHTTPResponse, for key: String) async { - guard let data = try? JSONEncoder().encode(entry) else { return } + var entries = await entries(for: key) + entries.removeAll { $0.variantIdentifier == entry.variantIdentifier } + entries.append(entry) + guard let data = try? JSONEncoder().encode(entries) else { return } try? data.write(to: fileURL(for: key), options: .atomic) } private func fileURL(for key: String) -> URL { - let name = Data(key.utf8).base64EncodedString().replacingOccurrences(of: "/", with: "_") + let name = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined() return directory.appendingPathComponent(name).appendingPathExtension("json") } } @@ -112,23 +155,26 @@ public struct CachingTransport: NetworkTransport { public func send(_ request: URLRequest) async throws -> (Data, URLResponse) { let key = cacheKey(for: request) - let stored = request.httpMethod == HTTPMethod.get.rawValue ? await cache.entry(for: key) : nil - let cached = stored?.matches(request) == true ? stored : nil + let requestDisallowsStorage = request.value(forHTTPHeaderField: "Cache-Control")?.lowercased().contains("no-store") == true + let cached = request.httpMethod == HTTPMethod.get.rawValue && !requestDisallowsStorage + ? await cache.entries(for: key).first(where: { $0.matches(request) }) + : nil if policy == .returnCacheDontLoad, let cached { return (cached.data, cached.makeResponse()) } guard policy != .returnCacheDontLoad else { throw CacheMissError() } - if policy == .returnCacheElseLoad, let cached, cached.isFresh { return (cached.data, cached.makeResponse()) } + if policy == .returnCacheElseLoad, let cached, cached.isFresh, !CacheControl.requiresRevalidation(cached.headers) { return (cached.data, cached.makeResponse()) } var request = request if let eTag = cached?.eTag { request.setValue(eTag, forHTTPHeaderField: "If-None-Match") } let result = try await upstream.send(request) if let response = result.1 as? HTTPURLResponse, response.statusCode == 304, let cached { - let refreshed = makeEntry(data: cached.data, response: response, request: request, fallbackURL: cached.url) + let refreshed = cached.mergingRevalidationHeaders(from: response, defaultTTL: defaultTTL) await cache.store(refreshed, for: key) return (cached.data, refreshed.makeResponse()) } if request.httpMethod == HTTPMethod.get.rawValue, let response = result.1 as? HTTPURLResponse, - NetworkConstants.HTTPStatus.successRange.contains(response.statusCode), !isNoStore(response) { + NetworkConstants.HTTPStatus.successRange.contains(response.statusCode), !requestDisallowsStorage, + !isNoStore(response), !response.variesByAllHeaders { await cache.store(makeEntry(data: result.0, response: response, request: request, fallbackURL: request.url), for: key) } return result @@ -142,28 +188,36 @@ public struct CachingTransport: NetworkTransport { let headers = response.allHeaderFields.reduce(into: [String: String]()) { result, item in if let key = item.key as? String { result[key] = String(describing: item.value) } } - let cacheControl = headers.first { $0.key.caseInsensitiveCompare("Cache-Control") == .orderedSame }?.value - let directive = cacheControl?.split(separator: ",").first { - $0.trimmingCharacters(in: .whitespaces).hasPrefix("max-age=") - } - let maxAgeValue = directive?.split(separator: "=").last.map(String.init) - let expiresValue = headers.first { $0.key.caseInsensitiveCompare("Expires") == .orderedSame }?.value - let expires = expiresValue.flatMap { parseHTTPDate($0) } - let maxAge = maxAgeValue.flatMap(TimeInterval.init) ?? expires.map { $0.timeIntervalSinceNow } ?? defaultTTL - let eTag = headers.first { $0.key.caseInsensitiveCompare("ETag") == .orderedSame }?.value - let vary = headers.first { $0.key.caseInsensitiveCompare("Vary") == .orderedSame }?.value + let eTag = headers.value(forHTTPHeaderField: "ETag") + let vary = headers.value(forHTTPHeaderField: "Vary") let varyHeaders = Dictionary(uniqueKeysWithValues: (vary?.split(separator: ",") ?? []).map { name in let field = name.trimmingCharacters(in: .whitespaces) return (field, request.value(forHTTPHeaderField: field) ?? "") }) - return CachedHTTPResponse(data: data, url: response.url ?? fallbackURL!, statusCode: response.statusCode, headers: headers, expiresAt: Date().addingTimeInterval(maxAge), eTag: eTag, varyHeaders: varyHeaders) + return CachedHTTPResponse(data: data, url: response.url ?? fallbackURL!, statusCode: response.statusCode, headers: headers, expiresAt: CacheControl.expiry(headers: headers, defaultTTL: defaultTTL), eTag: eTag, varyHeaders: varyHeaders) } private func isNoStore(_ response: HTTPURLResponse) -> Bool { response.value(forHTTPHeaderField: "Cache-Control")?.lowercased().contains("no-store") == true } - private func parseHTTPDate(_ value: String) -> Date? { +} + +private enum CacheControl { + static func requiresRevalidation(_ headers: [String: String]) -> Bool { + headers.value(forHTTPHeaderField: "Cache-Control")?.lowercased().contains("no-cache") == true + } + + static func expiry(headers: [String: String], defaultTTL: TimeInterval) -> Date { + let control = headers.value(forHTTPHeaderField: "Cache-Control")?.lowercased() ?? "" + if control.contains("no-cache") { return Date() } + let maxAge = control.split(separator: ",").first { $0.trimmingCharacters(in: .whitespaces).hasPrefix("max-age=") } + .flatMap { TimeInterval($0.split(separator: "=").last ?? "") } + let expires = headers.value(forHTTPHeaderField: "Expires").flatMap(parseHTTPDate) + return Date().addingTimeInterval(maxAge ?? expires.map { $0.timeIntervalSinceNow } ?? defaultTTL) + } + + private static func parseHTTPDate(_ value: String) -> Date? { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) @@ -172,6 +226,30 @@ public struct CachingTransport: NetworkTransport { } } +private extension CachedHTTPResponse { + var variantIdentifier: String { + varyHeaders.sorted { $0.key < $1.key }.map { "\($0.key.lowercased())=\($0.value)" }.joined(separator: "&") + } +} + +private extension Dictionary where Key == String, Value == String { + func value(forHTTPHeaderField field: String) -> String? { + first { $0.key.caseInsensitiveCompare(field) == .orderedSame }?.value + } +} + +private extension HTTPURLResponse { + var headers: [String: String] { + allHeaderFields.reduce(into: [:]) { result, item in + if let key = item.key as? String { result[key] = String(describing: item.value) } + } + } + + var variesByAllHeaders: Bool { + value(forHTTPHeaderField: "Vary")?.split(separator: ",").contains { $0.trimmingCharacters(in: .whitespaces) == "*" } == true + } +} + /// Indicates an offline cache-only request had no matching entry. public struct CacheMissError: LocalizedError, Sendable { public init() {} diff --git a/Tests/NetworkingKitTests/NetworkingKitTests.swift b/Tests/NetworkingKitTests/NetworkingKitTests.swift index 4595e94..44d6ca7 100644 --- a/Tests/NetworkingKitTests/NetworkingKitTests.swift +++ b/Tests/NetworkingKitTests/NetworkingKitTests.swift @@ -116,6 +116,73 @@ final class NetworkingKitTests: XCTestCase { XCTAssertEqual(counter.value, 1) } + func testCachingTransportKeepsSeparateVaryVariants() async throws { + let counter = AttemptCounter() + let upstream = StubTransport { request in + _ = counter.increment() + let language = request.value(forHTTPHeaderField: "Accept-Language") ?? "unknown" + let response = HTTPURLResponse( + url: try! XCTUnwrap(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Cache-Control": "max-age=60", "Vary": "Accept-Language"] + )! + return (response, Data(language.utf8)) + } + let transport = CachingTransport(upstream: upstream, cache: InMemoryResponseCache()) + let url = URL(string: "https://example.com/greeting")! + var english = URLRequest(url: url) + english.setValue("en", forHTTPHeaderField: "Accept-Language") + var chinese = URLRequest(url: url) + chinese.setValue("zh-Hans", forHTTPHeaderField: "Accept-Language") + + _ = try await transport.send(english) + _ = try await transport.send(chinese) + let result = try await transport.send(english) + + XCTAssertEqual(String(data: result.0, encoding: .utf8), "en") + XCTAssertEqual(counter.value, 2) + } + + func testCachingTransportMerges304HeadersWithCachedResponse() async throws { + let counter = AttemptCounter() + let upstream = StubTransport { request in + let attempt = counter.increment() + let headers = attempt == 1 + ? ["Cache-Control": "max-age=0", "ETag": "v1", "Content-Type": "application/json"] + : ["Cache-Control": "max-age=60"] + let statusCode = attempt == 1 ? 200 : 304 + let response = HTTPURLResponse(url: try! XCTUnwrap(request.url), statusCode: statusCode, httpVersion: nil, headerFields: headers)! + return (response, Data("cached".utf8)) + } + let transport = CachingTransport(upstream: upstream, cache: InMemoryResponseCache()) + let request = URLRequest(url: URL(string: "https://example.com/revalidate")!) + + _ = try await transport.send(request) + let revalidated = try await transport.send(request) + _ = try await transport.send(request) + + let response = revalidated.1 as? HTTPURLResponse + XCTAssertEqual(response?.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(counter.value, 2) + } + + func testCachingTransportDoesNotStoreVaryAllResponses() async throws { + let counter = AttemptCounter() + let upstream = StubTransport { request in + _ = counter.increment() + let response = HTTPURLResponse(url: try! XCTUnwrap(request.url), statusCode: 200, httpVersion: nil, headerFields: ["Vary": "*"])! + return (response, Data()) + } + let transport = CachingTransport(upstream: upstream, cache: InMemoryResponseCache()) + let request = URLRequest(url: URL(string: "https://example.com/private")!) + + _ = try await transport.send(request) + _ = try await transport.send(request) + + XCTAssertEqual(counter.value, 2) + } + func testCircuitBreakerAllowsOneHalfOpenRecoveryProbe() async throws { let breaker = CircuitBreaker(failureThreshold: 1, resetTimeout: 0)