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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

All notable changes to NetworkingKit are documented in this file.

## 2.3.1 - Unreleased
## 2.3.2 - Unreleased

### Added

- Configurable disk-cache capacity, least-recent-access pruning, footprint statistics, and explicit clearing.

## 2.3.1 - 2026-07-19

### Changed

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,12 @@ The cache honors request and response `Cache-Control: no-store`, `no-cache`, and
```swift
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("NetworkingKitCache")
let cache = DiskResponseCache(directory: directory)
let cache = DiskResponseCache(directory: directory, maximumSize: 50 * 1_024 * 1_024)
let transport = CachingTransport(upstream: URLSessionTransport(session: session), cache: cache)
```

Call `await cache.statistics()` for its file count and total size, and `await cache.removeAll()` to clear it on logout or when the app needs to reclaim storage. Files are pruned by least-recent access when the configured size limit is exceeded.

Use `CertificatePinningEvaluator` and `ServerTrustSessionDelegate` to pin leaf-certificate DER data per host. Keep at least two pins during certificate rotation, and retain normal system trust evaluation before accepting a pin.

```swift
Expand Down
4 changes: 3 additions & 1 deletion README.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,12 @@ let transport = CachingTransport(
```swift
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("NetworkingKitCache")
let cache = DiskResponseCache(directory: directory)
let cache = DiskResponseCache(directory: directory, maximumSize: 50 * 1_024 * 1_024)
let transport = CachingTransport(upstream: URLSessionTransport(session: session), cache: cache)
```

可通过 `await cache.statistics()` 查看文件数量和总大小;用户退出登录或 App 需要回收空间时调用 `await cache.removeAll()` 清空。超过配置容量后,会按最近最少访问顺序清理文件。

使用 `CertificatePinningEvaluator` 和 `ServerTrustSessionDelegate` 可按 Host 固定叶子证书的 DER 数据。证书轮换期间至少保留两个 pin,并在接受 pin 前继续使用系统信任校验。

```swift
Expand Down
65 changes: 62 additions & 3 deletions Sources/NetworkingKit/Core/CachingTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,16 @@ public actor InMemoryResponseCache: NetworkResponseCaching {
/// A JSON-backed cache that persists entries across app launches.
public actor DiskResponseCache: NetworkResponseCaching {
private let directory: URL

public init(directory: URL) {
private let maximumSize: Int

/// Creates a persistent cache.
///
/// - Parameters:
/// - directory: The private directory that stores cache entries.
/// - maximumSize: Maximum on-disk size in bytes. Least recently accessed files are removed when exceeded.
public init(directory: URL, maximumSize: Int = 50 * 1_024 * 1_024) {
self.directory = directory
self.maximumSize = max(0, maximumSize)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}

Expand All @@ -120,7 +127,9 @@ public actor DiskResponseCache: NetworkResponseCaching {
}

public func entries(for key: String) async -> [CachedHTTPResponse] {
guard let data = try? Data(contentsOf: fileURL(for: key)) else { return [] }
let url = fileURL(for: key)
guard let data = try? Data(contentsOf: url) else { return [] }
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
if let entries = try? JSONDecoder().decode([CachedHTTPResponse].self, from: data) { return entries }
return (try? JSONDecoder().decode(CachedHTTPResponse.self, from: data)).map { [$0] } ?? []
}
Expand All @@ -131,12 +140,62 @@ public actor DiskResponseCache: NetworkResponseCaching {
entries.append(entry)
guard let data = try? JSONEncoder().encode(entries) else { return }
try? data.write(to: fileURL(for: key), options: .atomic)
pruneIfNeeded()
}

/// Removes every persistent cache entry.
public func removeAll() {
guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return }
for file in files where file.pathExtension == "json" {
try? FileManager.default.removeItem(at: file)
}
}

/// Returns the current persistent-cache footprint.
public func statistics() -> DiskResponseCacheStatistics {
let files = cacheFiles()
return DiskResponseCacheStatistics(entryCount: files.count, totalSize: files.reduce(0) { $0 + $1.size })
}

private func fileURL(for key: String) -> URL {
let name = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
return directory.appendingPathComponent(name).appendingPathExtension("json")
}

private func pruneIfNeeded() {
var files = cacheFiles().sorted { $0.modificationDate < $1.modificationDate }
var totalSize = files.reduce(0) { $0 + $1.size }
while totalSize > maximumSize, let oldest = files.first {
try? FileManager.default.removeItem(at: oldest.url)
totalSize -= oldest.size
files.removeFirst()
}
}

private func cacheFiles() -> [(url: URL, size: Int, modificationDate: Date)] {
let keys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey]
guard let urls = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: Array(keys)) else { return [] }
return urls.compactMap { url in
guard url.pathExtension == "json",
let values = try? url.resourceValues(forKeys: keys),
let size = values.fileSize else { return nil }
return (url, size, values.contentModificationDate ?? .distantPast)
}
}
}

/// A point-in-time view of a `DiskResponseCache` footprint.
public struct DiskResponseCacheStatistics: Sendable, Equatable {
/// The number of cache files currently stored.
public let entryCount: Int
/// The total cache size in bytes.
public let totalSize: Int

/// Creates disk-cache statistics.
public init(entryCount: Int, totalSize: Int) {
self.entryCount = entryCount
self.totalSize = totalSize
}
}

/// Wraps another transport with a cache for successful GET responses.
Expand Down
24 changes: 24 additions & 0 deletions Tests/NetworkingKitTests/NetworkingKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,30 @@ final class NetworkingKitTests: XCTestCase {
XCTAssertEqual(counter.value, 2)
}

func testDiskResponseCacheReportsStatisticsAndCanBeCleared() async {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let cache = DiskResponseCache(directory: directory, maximumSize: 10_000)
let entry = CachedHTTPResponse(
data: Data("cached".utf8),
url: URL(string: "https://example.com/cache")!,
statusCode: 200,
headers: [:],
expiresAt: Date().addingTimeInterval(60),
eTag: nil,
varyHeaders: [:]
)

await cache.store(entry, for: "GET https://example.com/cache")
let stored = await cache.statistics()
XCTAssertEqual(stored.entryCount, 1)
XCTAssertGreaterThan(stored.totalSize, 0)

await cache.removeAll()
let cleared = await cache.statistics()
XCTAssertEqual(cleared, .init(entryCount: 0, totalSize: 0))
}

func testCircuitBreakerAllowsOneHalfOpenRecoveryProbe() async throws {
let breaker = CircuitBreaker(failureThreshold: 1, resetTimeout: 0)

Expand Down
Loading