krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.6.0: Hutch/Networking/APICache.swift · raw
1import CryptoKit
2import Foundation
3
4enum CachePolicy: Sendable, Equatable {
5 case networkOnly
6 case cacheOnly
7 case cacheFirstThenRefresh
8 case refreshIgnoringCache
9}
10
11enum CacheResourceType: String, Codable, Sendable {
12 case repositoryDetail
13 case repositoryList
14 case repositoryTree
15 case repositoryFile
16 case repositoryReadme
17 case ticketDetail
18 case ticketList
19 case buildDetail
20 case buildList
21 case buildLog
22 case userProfile
23 case status
24 case pasteList
25 case debug
26}
27
28struct CacheEntryMetadata: Codable, Sendable, Equatable {
29 let cacheKey: String
30 let resourceType: CacheResourceType
31 let fetchedAt: Date
32 let expiresAt: Date
33 var lastAccessedAt: Date
34 let payloadHash: String
35 let schemaVersion: Int
36 let payloadSize: Int
37
38 nonisolated func isExpired(now: Date = Date()) -> Bool {
39 expiresAt <= now
40 }
41}
42
43struct APICacheEntry: Sendable {
44 var metadata: CacheEntryMetadata
45 let payload: Data
46}
47
48struct CachedValue<Value> {
49 let value: Value
50 let metadata: CacheEntryMetadata?
51 let source: CacheValueSource
52
53 var isFromCache: Bool { source == .cache }
54 var isStale: Bool { metadata?.isExpired() ?? false }
55}
56
57enum CacheValueSource: Sendable, Equatable {
58 case cache
59 case network
60}
61
62enum APICacheError: LocalizedError, Sendable {
63 case miss
64 case entryTooLarge(Int)
65 case cacheTooLarge
66
67 var errorDescription: String? {
68 switch self {
69 case .miss:
70 "No cached data is available."
71 case .entryTooLarge(let bytes):
72 "The response is too large to cache (\(bytes) bytes)."
73 case .cacheTooLarge:
74 "The cache size limit was exceeded."
75 }
76 }
77}
78
79struct APICacheConfiguration: Sendable {
80 var directory: URL
81 var maxCacheSizeBytes: Int
82 var maxEntrySizeBytes: Int
83 var memoryEntryLimit: Int
84 var schemaVersion: Int
85
86 nonisolated static func accountScoped(accountID: String) -> APICacheConfiguration {
87 let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
88 ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
89 return APICacheConfiguration(
90 directory: base
91 .appendingPathComponent("Hutch", isDirectory: true)
92 .appendingPathComponent("APICache", isDirectory: true)
93 .appendingPathComponent(accountID, isDirectory: true),
94 maxCacheSizeBytes: 50 * 1024 * 1024,
95 maxEntrySizeBytes: 2 * 1024 * 1024,
96 memoryEntryLimit: 64,
97 schemaVersion: 1
98 )
99 }
100
101 nonisolated static func temporary(directory: URL) -> APICacheConfiguration {
102 APICacheConfiguration(
103 directory: directory,
104 maxCacheSizeBytes: 4 * 1024 * 1024,
105 maxEntrySizeBytes: 512 * 1024,
106 memoryEntryLimit: 16,
107 schemaVersion: 1
108 )
109 }
110}
111
112protocol APICache: Sendable {
113 func read(cacheKey: String) async throws -> APICacheEntry
114 func write(payload: Data, cacheKey: String, resourceType: CacheResourceType, ttl: TimeInterval) async throws -> CacheEntryMetadata
115 func remove(cacheKey: String) async
116 func removeByPrefix(_ prefix: String) async
117 func clearAll() async
118 func pruneExpired(now: Date) async
119 func pruneToSizeLimit() async
120}
121
122actor PersistentAPICache: APICache {
123 private struct StoredEntry: Codable, Sendable {
124 var metadata: CacheEntryMetadata
125 let payload: Data
126 }
127
128 private let configuration: APICacheConfiguration
129 private var memoryEntries: [String: APICacheEntry] = [:]
130 private var memoryOrder: [String] = []
131 private var knownMetadata: [String: CacheEntryMetadata] = [:]
132 private var writeCountSincePrune = 0
133
134 init(configuration: APICacheConfiguration) {
135 self.configuration = configuration
136 }
137
138 func read(cacheKey: String) async throws -> APICacheEntry {
139 if var entry = memoryEntries[cacheKey] {
140 entry.metadata.lastAccessedAt = Date()
141 memoryEntries[cacheKey] = entry
142 markMemoryUse(cacheKey)
143 try? persist(entry)
144 return entry
145 }
146
147 let url = fileURL(for: cacheKey)
148 guard FileManager.default.fileExists(atPath: url.path) else {
149 throw APICacheError.miss
150 }
151
152 var stored = try decodeEntry(from: url)
153 guard stored.metadata.schemaVersion == configuration.schemaVersion else {
154 try? FileManager.default.removeItem(at: url)
155 throw APICacheError.miss
156 }
157
158 stored.metadata.lastAccessedAt = Date()
159 let entry = APICacheEntry(metadata: stored.metadata, payload: stored.payload)
160 knownMetadata[cacheKey] = stored.metadata
161 remember(entry)
162 try? persist(entry)
163 return entry
164 }
165
166 func write(
167 payload: Data,
168 cacheKey: String,
169 resourceType: CacheResourceType,
170 ttl: TimeInterval
171 ) async throws -> CacheEntryMetadata {
172 guard payload.count <= configuration.maxEntrySizeBytes else {
173 throw APICacheError.entryTooLarge(payload.count)
174 }
175
176 try ensureDirectoryExists()
177 let now = Date()
178 let payloadHash = Self.payloadHash(payload)
179 if let existing = try? await read(cacheKey: cacheKey),
180 existing.metadata.payloadHash == payloadHash {
181 let metadata = CacheEntryMetadata(
182 cacheKey: cacheKey,
183 resourceType: resourceType,
184 fetchedAt: now,
185 expiresAt: now.addingTimeInterval(ttl),
186 lastAccessedAt: now,
187 payloadHash: payloadHash,
188 schemaVersion: configuration.schemaVersion,
189 payloadSize: payload.count
190 )
191 let entry = APICacheEntry(metadata: metadata, payload: payload)
192 remember(entry)
193 try persist(entry)
194 return metadata
195 }
196
197 let metadata = CacheEntryMetadata(
198 cacheKey: cacheKey,
199 resourceType: resourceType,
200 fetchedAt: now,
201 expiresAt: now.addingTimeInterval(ttl),
202 lastAccessedAt: now,
203 payloadHash: payloadHash,
204 schemaVersion: configuration.schemaVersion,
205 payloadSize: payload.count
206 )
207 let entry = APICacheEntry(metadata: metadata, payload: payload)
208 remember(entry)
209 try persist(entry)
210
211 writeCountSincePrune += 1
212 if writeCountSincePrune >= 12 {
213 writeCountSincePrune = 0
214 await pruneExpired(now: now)
215 await pruneToSizeLimit()
216 }
217 return metadata
218 }
219
220 func remove(cacheKey: String) async {
221 memoryEntries.removeValue(forKey: cacheKey)
222 memoryOrder.removeAll { $0 == cacheKey }
223 knownMetadata.removeValue(forKey: cacheKey)
224 try? FileManager.default.removeItem(at: fileURL(for: cacheKey))
225 }
226
227 func removeByPrefix(_ prefix: String) async {
228 await loadKnownMetadataIfNeeded()
229 for key in knownMetadata.keys where key.hasPrefix(prefix) {
230 await remove(cacheKey: key)
231 }
232 }
233
234 func clearAll() async {
235 memoryEntries.removeAll()
236 memoryOrder.removeAll()
237 knownMetadata.removeAll()
238 try? FileManager.default.removeItem(at: configuration.directory)
239 }
240
241 func pruneExpired(now: Date = Date()) async {
242 await loadKnownMetadataIfNeeded()
243 for metadata in knownMetadata.values where metadata.isExpired(now: now) {
244 await remove(cacheKey: metadata.cacheKey)
245 }
246 }
247
248 func pruneToSizeLimit() async {
249 await loadKnownMetadataIfNeeded()
250 var totalSize = knownMetadata.values.reduce(0) { $0 + $1.payloadSize }
251 guard totalSize > configuration.maxCacheSizeBytes else { return }
252
253 let victims = knownMetadata.values.sorted { $0.lastAccessedAt < $1.lastAccessedAt }
254 for metadata in victims {
255 await remove(cacheKey: metadata.cacheKey)
256 totalSize -= metadata.payloadSize
257 if totalSize <= configuration.maxCacheSizeBytes { break }
258 }
259 }
260
261 private func remember(_ entry: APICacheEntry) {
262 memoryEntries[entry.metadata.cacheKey] = entry
263 knownMetadata[entry.metadata.cacheKey] = entry.metadata
264 markMemoryUse(entry.metadata.cacheKey)
265 while memoryOrder.count > configuration.memoryEntryLimit, let evicted = memoryOrder.first {
266 memoryOrder.removeFirst()
267 memoryEntries.removeValue(forKey: evicted)
268 }
269 }
270
271 private func markMemoryUse(_ cacheKey: String) {
272 memoryOrder.removeAll { $0 == cacheKey }
273 memoryOrder.append(cacheKey)
274 }
275
276 private func persist(_ entry: APICacheEntry) throws {
277 try ensureDirectoryExists()
278 let stored = StoredEntry(metadata: entry.metadata, payload: entry.payload)
279 let data = try JSONEncoder.srhtCache.encode(stored)
280 try data.write(to: fileURL(for: entry.metadata.cacheKey), options: [.atomic])
281 }
282
283 private func decodeEntry(from url: URL) throws -> StoredEntry {
284 let data = try Data(contentsOf: url)
285 return try JSONDecoder.srhtCache.decode(StoredEntry.self, from: data)
286 }
287
288 private func loadKnownMetadataIfNeeded() async {
289 guard knownMetadata.isEmpty else { return }
290 guard let urls = try? FileManager.default.contentsOfDirectory(
291 at: configuration.directory,
292 includingPropertiesForKeys: nil
293 ) else { return }
294
295 for url in urls where url.pathExtension == "json" {
296 guard let stored = try? decodeEntry(from: url) else { continue }
297 knownMetadata[stored.metadata.cacheKey] = stored.metadata
298 }
299 }
300
301 private func ensureDirectoryExists() throws {
302 if !FileManager.default.fileExists(atPath: configuration.directory.path) {
303 try FileManager.default.createDirectory(
304 at: configuration.directory,
305 withIntermediateDirectories: true
306 )
307 }
308 }
309
310 private func fileURL(for cacheKey: String) -> URL {
311 configuration.directory
312 .appendingPathComponent(Self.payloadHash(Data(cacheKey.utf8)))
313 .appendingPathExtension("json")
314 }
315
316 nonisolated private static func payloadHash(_ data: Data) -> String {
317 SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
318 }
319}
320
321extension JSONEncoder {
322 nonisolated static var srhtCache: JSONEncoder {
323 let encoder = JSONEncoder()
324 encoder.dateEncodingStrategy = .iso8601
325 return encoder
326 }
327}
328
329extension JSONDecoder {
330 nonisolated static var srhtCache: JSONDecoder {
331 let decoder = JSONDecoder()
332 decoder.dateDecodingStrategy = .iso8601
333 return decoder
334 }
335}