diff options
| author | Christian Cleberg <[email protected]> | 2026-05-06 20:30:34 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-05-06 20:30:34 -0500 |
| commit | 57e4f34b4613c09beb0cb757ac2ba2b43cc04daf (patch) | |
| tree | 4c83a914f36919a5d1164fa39e7e0d5915c8730a /Hutch/Networking | |
| parent | fba49d0955a6030406956b2bb1c2a60d184cfda6 (diff) | |
| download | hutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.tar.gz hutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.tar.bz2 hutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.zip | |
feat: add persistent stale-while-revalidate API cache
Introduce an actor-backed persistent cache layer at the SRHTClient boundary
for read-only SourceHut data. Cache entries now store stable metadata
including key, resource type, fetched/expires/access timestamps, payload
hash, schema version, and payload size, with bounded memory and disk usage.
Add centralized cache key builders and TTL defaults for repository, file,
ticket, build, log, profile, status, and list-style resources. Support
networkOnly, cacheOnly, cacheFirstThenRefresh, and refreshIgnoringCache
policies, plus request coalescing for duplicate in-flight cache keys.
Integrate first-pass caching into high-value low-risk read paths:
- build detail and completed/active build logs
- ticket detail
- README lookup
- repository tree, blob, and linked file reads
Keep mutation paths network-only and add simple prefix invalidation after
ticket and build mutations. Add compact cached/stale UI status rows and a
Settings action to clear the persistent cache.
Add focused cache tests covering round trips, expiration, stale fallback,
policy behavior, request coalescing, prefix invalidation, size limits, LRU
pruning, expired pruning, and mutation bypass behavior. Document storage,
key, TTL, invalidation, limitations, and next recommended targets.
Diffstat (limited to 'Hutch/Networking')
| -rw-r--r-- | Hutch/Networking/APICache.swift | 337 | ||||
| -rw-r--r-- | Hutch/Networking/APICacheKeys.swift | 105 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 299 |
3 files changed, 740 insertions, 1 deletions
diff --git a/Hutch/Networking/APICache.swift b/Hutch/Networking/APICache.swift new file mode 100644 index 0000000..9c1a262 --- /dev/null +++ b/Hutch/Networking/APICache.swift @@ -0,0 +1,337 @@ +import CryptoKit +import Foundation + +enum CachePolicy: Sendable, Equatable { + case networkOnly + case cacheOnly + case cacheFirstThenRefresh + case refreshIgnoringCache +} + +enum CacheResourceType: String, Codable, Sendable { + case repositoryDetail + case repositoryList + case repositoryTree + case repositoryFile + case repositoryReadme + case ticketDetail + case ticketList + case buildDetail + case buildList + case buildLog + case userProfile + case status + case pasteList + case debug +} + +struct CacheEntryMetadata: Codable, Sendable, Equatable { + let cacheKey: String + let resourceType: CacheResourceType + let fetchedAt: Date + let expiresAt: Date + var lastAccessedAt: Date + let payloadHash: String + let schemaVersion: Int + let payloadSize: Int + + func isExpired(now: Date = Date()) -> Bool { + expiresAt <= now + } +} + +struct APICacheEntry: Sendable { + var metadata: CacheEntryMetadata + let payload: Data +} + +struct CachedValue<Value> { + let value: Value + let metadata: CacheEntryMetadata? + let source: CacheValueSource + + var isFromCache: Bool { source == .cache } + var isStale: Bool { metadata?.isExpired() ?? false } +} + +enum CacheValueSource: Sendable, Equatable { + case cache + case network +} + +enum APICacheError: LocalizedError, Sendable { + case miss + case entryTooLarge(Int) + case cacheTooLarge + + var errorDescription: String? { + switch self { + case .miss: + "No cached data is available." + case .entryTooLarge(let bytes): + "The response is too large to cache (\(bytes) bytes)." + case .cacheTooLarge: + "The cache size limit was exceeded." + } + } +} + +struct APICacheConfiguration: Sendable { + var directory: URL + var maxCacheSizeBytes: Int + var maxEntrySizeBytes: Int + var memoryEntryLimit: Int + var schemaVersion: Int + + static func accountScoped(accountID: String) -> APICacheConfiguration { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + return APICacheConfiguration( + directory: base + .appendingPathComponent("Hutch", isDirectory: true) + .appendingPathComponent("APICache", isDirectory: true) + .appendingPathComponent(accountID, isDirectory: true), + maxCacheSizeBytes: 50 * 1024 * 1024, + maxEntrySizeBytes: 2 * 1024 * 1024, + memoryEntryLimit: 64, + schemaVersion: 1 + ) + } + + static func temporary(directory: URL) -> APICacheConfiguration { + APICacheConfiguration( + directory: directory, + maxCacheSizeBytes: 4 * 1024 * 1024, + maxEntrySizeBytes: 512 * 1024, + memoryEntryLimit: 16, + schemaVersion: 1 + ) + } +} + +protocol APICache: Sendable { + func read(cacheKey: String) async throws -> APICacheEntry + func write(payload: Data, cacheKey: String, resourceType: CacheResourceType, ttl: TimeInterval) async throws -> CacheEntryMetadata + func remove(cacheKey: String) async + func removeByPrefix(_ prefix: String) async + func clearAll() async + func pruneExpired(now: Date) async + func pruneToSizeLimit() async +} + +actor PersistentAPICache: APICache { + private struct StoredEntry: Codable, Sendable { + var metadata: CacheEntryMetadata + let payload: Data + } + + private let configuration: APICacheConfiguration + private let fileManager: FileManager + private var memoryEntries: [String: APICacheEntry] = [:] + private var memoryOrder: [String] = [] + private var knownMetadata: [String: CacheEntryMetadata] = [:] + private var writeCountSincePrune = 0 + + init(configuration: APICacheConfiguration, fileManager: FileManager = .default) { + self.configuration = configuration + self.fileManager = fileManager + } + + func read(cacheKey: String) async throws -> APICacheEntry { + if var entry = memoryEntries[cacheKey] { + entry.metadata.lastAccessedAt = Date() + memoryEntries[cacheKey] = entry + markMemoryUse(cacheKey) + try? persist(entry) + return entry + } + + let url = fileURL(for: cacheKey) + guard fileManager.fileExists(atPath: url.path) else { + throw APICacheError.miss + } + + var stored = try decodeEntry(from: url) + guard stored.metadata.schemaVersion == configuration.schemaVersion else { + try? fileManager.removeItem(at: url) + throw APICacheError.miss + } + + stored.metadata.lastAccessedAt = Date() + let entry = APICacheEntry(metadata: stored.metadata, payload: stored.payload) + knownMetadata[cacheKey] = stored.metadata + remember(entry) + try? persist(entry) + return entry + } + + func write( + payload: Data, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> CacheEntryMetadata { + guard payload.count <= configuration.maxEntrySizeBytes else { + throw APICacheError.entryTooLarge(payload.count) + } + + try ensureDirectoryExists() + let now = Date() + let payloadHash = Self.payloadHash(payload) + if let existing = try? await read(cacheKey: cacheKey), + existing.metadata.payloadHash == payloadHash { + let metadata = CacheEntryMetadata( + cacheKey: cacheKey, + resourceType: resourceType, + fetchedAt: now, + expiresAt: now.addingTimeInterval(ttl), + lastAccessedAt: now, + payloadHash: payloadHash, + schemaVersion: configuration.schemaVersion, + payloadSize: payload.count + ) + let entry = APICacheEntry(metadata: metadata, payload: payload) + remember(entry) + try persist(entry) + return metadata + } + + let metadata = CacheEntryMetadata( + cacheKey: cacheKey, + resourceType: resourceType, + fetchedAt: now, + expiresAt: now.addingTimeInterval(ttl), + lastAccessedAt: now, + payloadHash: payloadHash, + schemaVersion: configuration.schemaVersion, + payloadSize: payload.count + ) + let entry = APICacheEntry(metadata: metadata, payload: payload) + remember(entry) + try persist(entry) + + writeCountSincePrune += 1 + if writeCountSincePrune >= 12 { + writeCountSincePrune = 0 + await pruneExpired(now: now) + await pruneToSizeLimit() + } + return metadata + } + + func remove(cacheKey: String) async { + memoryEntries.removeValue(forKey: cacheKey) + memoryOrder.removeAll { $0 == cacheKey } + knownMetadata.removeValue(forKey: cacheKey) + try? fileManager.removeItem(at: fileURL(for: cacheKey)) + } + + func removeByPrefix(_ prefix: String) async { + await loadKnownMetadataIfNeeded() + for key in knownMetadata.keys where key.hasPrefix(prefix) { + await remove(cacheKey: key) + } + } + + func clearAll() async { + memoryEntries.removeAll() + memoryOrder.removeAll() + knownMetadata.removeAll() + try? fileManager.removeItem(at: configuration.directory) + } + + func pruneExpired(now: Date = Date()) async { + await loadKnownMetadataIfNeeded() + for metadata in knownMetadata.values where metadata.isExpired(now: now) { + await remove(cacheKey: metadata.cacheKey) + } + } + + func pruneToSizeLimit() async { + await loadKnownMetadataIfNeeded() + var totalSize = knownMetadata.values.reduce(0) { $0 + $1.payloadSize } + guard totalSize > configuration.maxCacheSizeBytes else { return } + + let victims = knownMetadata.values.sorted { $0.lastAccessedAt < $1.lastAccessedAt } + for metadata in victims { + await remove(cacheKey: metadata.cacheKey) + totalSize -= metadata.payloadSize + if totalSize <= configuration.maxCacheSizeBytes { break } + } + } + + private func remember(_ entry: APICacheEntry) { + memoryEntries[entry.metadata.cacheKey] = entry + knownMetadata[entry.metadata.cacheKey] = entry.metadata + markMemoryUse(entry.metadata.cacheKey) + while memoryOrder.count > configuration.memoryEntryLimit, let evicted = memoryOrder.first { + memoryOrder.removeFirst() + memoryEntries.removeValue(forKey: evicted) + } + } + + private func markMemoryUse(_ cacheKey: String) { + memoryOrder.removeAll { $0 == cacheKey } + memoryOrder.append(cacheKey) + } + + private func persist(_ entry: APICacheEntry) throws { + try ensureDirectoryExists() + let stored = StoredEntry(metadata: entry.metadata, payload: entry.payload) + let data = try JSONEncoder.srhtCache.encode(stored) + try data.write(to: fileURL(for: entry.metadata.cacheKey), options: [.atomic]) + } + + private func decodeEntry(from url: URL) throws -> StoredEntry { + let data = try Data(contentsOf: url) + return try JSONDecoder.srhtCache.decode(StoredEntry.self, from: data) + } + + private func loadKnownMetadataIfNeeded() async { + guard knownMetadata.isEmpty else { return } + guard let urls = try? fileManager.contentsOfDirectory( + at: configuration.directory, + includingPropertiesForKeys: nil + ) else { return } + + for url in urls where url.pathExtension == "json" { + guard let stored = try? decodeEntry(from: url) else { continue } + knownMetadata[stored.metadata.cacheKey] = stored.metadata + } + } + + private func ensureDirectoryExists() throws { + if !fileManager.fileExists(atPath: configuration.directory.path) { + try fileManager.createDirectory( + at: configuration.directory, + withIntermediateDirectories: true + ) + } + } + + private func fileURL(for cacheKey: String) -> URL { + configuration.directory + .appendingPathComponent(Self.payloadHash(Data(cacheKey.utf8))) + .appendingPathExtension("json") + } + + private static func payloadHash(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +extension JSONEncoder { + static var srhtCache: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } +} + +extension JSONDecoder { + static var srhtCache: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Hutch/Networking/APICacheKeys.swift b/Hutch/Networking/APICacheKeys.swift new file mode 100644 index 0000000..5e57a51 --- /dev/null +++ b/Hutch/Networking/APICacheKeys.swift @@ -0,0 +1,105 @@ +import Foundation + +enum APICacheKeys { + static func repositories(service: SRHTService, owner: String? = nil, cursor: String? = nil, filter: String? = nil) -> String { + make([ + service.rawValue, + "repositories", + owner.map { "owner:\(normalize($0))" }, + cursor.map { "cursor:\($0)" }, + filter.map { "filter:\(normalize($0))" } + ]) + } + + static func repository(service: SRHTService, owner: String, name: String) -> String { + make([service.rawValue, "repository", normalize(owner), normalize(name)]) + } + + static func repositoryRID(service: SRHTService, rid: String) -> String { + make([service.rawValue, "repository", "rid:\(rid)"]) + } + + static func refs(service: SRHTService, rid: String, cursor: String? = nil) -> String { + make([service.rawValue, "refs", "rid:\(rid)", cursor.map { "cursor:\($0)" }]) + } + + static func readme(service: SRHTService, rid: String, path: String? = nil, ref: String = "HEAD") -> String { + make([service.rawValue, "readme", "rid:\(rid)", "ref:\(ref)", path.map { "path:\($0)" }]) + } + + static func treeRoot(service: SRHTService, rid: String, ref: String) -> String { + make([service.rawValue, "tree", "rid:\(rid)", "ref:\(ref)", "root"]) + } + + static func treeEntries(service: SRHTService, rid: String, treeId: String, cursor: String? = nil) -> String { + make([service.rawValue, "tree", "rid:\(rid)", "tree:\(treeId)", cursor.map { "cursor:\($0)" }]) + } + + static func blob(service: SRHTService, rid: String, blobId: String) -> String { + make([service.rawValue, "blob", "rid:\(rid)", "blob:\(blobId)"]) + } + + static func path(service: SRHTService, rid: String, ref: String, path: String) -> String { + make([service.rawValue, "path", "rid:\(rid)", "ref:\(ref)", "path:\(path)"]) + } + + static func ticketDetail(owner: String, trackerRid: String, ticketId: Int) -> String { + make([SRHTService.todo.rawValue, "ticket", normalize(owner), "tracker:\(trackerRid)", "ticket:\(ticketId)"]) + } + + static func trackerLabels(trackerRid: String) -> String { + make([SRHTService.todo.rawValue, "tracker-labels", "tracker:\(trackerRid)"]) + } + + static func builds(cursor: String? = nil, filter: String? = nil) -> String { + make([SRHTService.builds.rawValue, "jobs", cursor.map { "cursor:\($0)" }, filter.map { "filter:\($0)" }]) + } + + static func buildDetail(jobId: Int) -> String { + make([SRHTService.builds.rawValue, "job", "id:\(jobId)"]) + } + + static func buildLog(url: URL, jobId: Int? = nil, task: String? = nil) -> String { + make([SRHTService.builds.rawValue, "log", jobId.map { "job:\($0)" }, task.map { "task:\($0)" }, url.absoluteString]) + } + + static func userRepositories(owner: String, cursor: String? = nil) -> String { + make([SRHTService.git.rawValue, "user-repositories", normalize(owner), cursor.map { "cursor:\($0)" }]) + } + + static func userTrackers(owner: String, cursor: String? = nil) -> String { + make([SRHTService.todo.rawValue, "user-trackers", normalize(owner), cursor.map { "cursor:\($0)" }]) + } + + static func pasteList(cursor: String? = nil) -> String { + make([SRHTService.paste.rawValue, "pastes", cursor.map { "cursor:\($0)" }]) + } + + static func prefix(_ components: String...) -> String { + make(components) + } + + private static func make(_ parts: [String?]) -> String { + parts.compactMap { $0?.replacingOccurrences(of: "|", with: "%7C") } + .joined(separator: "|") + } + + private static func normalize(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +enum APICacheTTLs { + // Active build data changes quickly; completed logs and content-addressed git data are effectively immutable. + static let activeBuild: TimeInterval = 15 + static let completedBuildDetail: TimeInterval = 60 * 60 + static let completedBuildLog: TimeInterval = 30 * 24 * 60 * 60 + static let ticketDetail: TimeInterval = 5 * 60 + static let ticketList: TimeInterval = 2 * 60 + static let repositoryMetadata: TimeInterval = 30 * 60 + static let repositoryList: TimeInterval = 5 * 60 + static let immutableFileContent: TimeInterval = 14 * 24 * 60 * 60 + static let movingRefFileContent: TimeInterval = 10 * 60 + static let userProfile: TimeInterval = 30 * 60 + static let status: TimeInterval = 5 * 60 +} diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index b4f8ee0..37fd6b8 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import os @@ -20,6 +21,8 @@ final class SRHTClient: Sendable { private let session: URLSession private let decoder: JSONDecoder private let encoder: JSONEncoder + private let cache: any APICache + private let requestCoalescer = RequestCoalescer() /// The personal access token used for `Authorization: Bearer` headers. /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. @@ -32,12 +35,19 @@ final class SRHTClient: Sendable { tokenLock.withLock { $0 != nil } } - init(session: URLSession = .shared, token: String? = nil) { + init( + session: URLSession = .shared, + token: String? = nil, + cache: (any APICache)? = nil + ) { self.session = session self.decoder = JSONDecoder() self.decoder.dateDecodingStrategy = .srhtFlexible self.encoder = JSONEncoder() self.tokenLock = OSAllocatedUnfairLock(initialState: token) + self.cache = cache ?? PersistentAPICache( + configuration: .accountScoped(accountID: token.map { Self.tokenCacheScope($0) } ?? "anonymous") + ) } /// Update the stored token (e.g. after the user saves a new one in Keychain). @@ -156,6 +166,130 @@ final class SRHTClient: Sendable { return result } + func executeCached<T: Decodable>( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + responseType _: T.Type, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval, + policy: CachePolicy = .cacheFirstThenRefresh + ) async throws -> CachedValue<T> { + switch policy { + case .networkOnly: + let data = try await performGraphQLRequest( + service: service, + query: query, + variables: variables + ) + let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables) + return CachedValue(value: value, metadata: nil, source: .network) + + case .cacheOnly: + let entry = try await cache.read(cacheKey: cacheKey) + let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables) + return CachedValue(value: value, metadata: entry.metadata, source: .cache) + + case .cacheFirstThenRefresh: + if let entry = try? await cache.read(cacheKey: cacheKey) { + let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables) + if entry.metadata.isExpired() { + Task.detached { [self] in + _ = try? await self.fetchAndCacheGraphQLData( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + } + } + return CachedValue(value: value, metadata: entry.metadata, source: .cache) + } + + let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + return CachedValue(value: value, metadata: metadata, source: .network) + + case .refreshIgnoringCache: + let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + return CachedValue(value: value, metadata: metadata, source: .network) + } + } + + func fetchCachedText( + url: URL, + cacheKey: String, + resourceType: CacheResourceType = .buildLog, + ttl: TimeInterval, + policy: CachePolicy = .cacheFirstThenRefresh + ) async throws -> CachedValue<String> { + switch policy { + case .networkOnly: + let text = try await fetchText(url: url) + return CachedValue(value: text, metadata: nil, source: .network) + case .cacheOnly: + let entry = try await cache.read(cacheKey: cacheKey) + guard let text = String(data: entry.payload, encoding: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Cached text is not UTF-8")) + ) + } + return CachedValue(value: text, metadata: entry.metadata, source: .cache) + case .cacheFirstThenRefresh: + if let entry = try? await cache.read(cacheKey: cacheKey), + let text = String(data: entry.payload, encoding: .utf8) { + if entry.metadata.isExpired() { + Task.detached { [self] in + _ = try? await self.fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + } + } + return CachedValue(value: text, metadata: entry.metadata, source: .cache) + } + let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return CachedValue(value: text, metadata: metadata, source: .network) + case .refreshIgnoringCache: + let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return CachedValue(value: text, metadata: metadata, source: .network) + } + } + + func cachedPayload(forKey cacheKey: String) async -> Data? { + if let entry = try? await cache.read(cacheKey: cacheKey) { + return entry.payload + } + return responseCache.get(forKey: cacheKey) + } + + func invalidateCache(prefix: String) async { + await cache.removeByPrefix(prefix) + } + + func removeCachedValue(forKey cacheKey: String) async { + await cache.remove(cacheKey: cacheKey) + responseCache.remove(forKey: cacheKey) + } + + func clearPersistentCache() async { + await cache.clearAll() + responseCache.clear() + } + // MARK: - Multipart Upload /// Execute a GraphQL mutation with a file upload using the @@ -614,6 +748,152 @@ final class SRHTClient: Sendable { // MARK: - Data Helper private extension SRHTClient { + func performGraphQLRequest( + service: SRHTService, + query: String, + variables: [String: any Sendable]? + ) async throws -> Data { + guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + + var request = URLRequest(url: service.url) + request.httpMethod = "POST" + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = GraphQLRequestBody( + query: query, + variables: variables?.mapValues { AnyCodable($0) } + ) + request.httpBody = try encoder.encode(body) + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + try throwGraphQLErrorsIfPresent(in: data) + throw SRHTError.httpError(http.statusCode) + } + } + + try throwGraphQLErrorsIfPresent(in: data) + return data + } + + func decodeGraphQLData<T: Decodable>( + _ data: Data, + service: SRHTService, + query: String, + variables: [String: any Sendable]? + ) throws -> T { + let graphQLResponse: GraphQLResponse<T> + do { + graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>" + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(String(describing: variables), privacy: .public) + error: + \(String(describing: error), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + #endif + throw SRHTError.decodingError(error) + } + + if let errors = graphQLResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + + guard let result = graphQLResponse.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) + ) + } + return result + } + + func fetchAndCacheGraphQL<T: Decodable>( + service: SRHTService, + query: String, + variables: [String: any Sendable]?, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> (T, CacheEntryMetadata?) { + let data = try await requestCoalescer.value(for: cacheKey) { + try await self.performGraphQLRequest(service: service, query: query, variables: variables) + } + let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables) + responseCache.set(data, forKey: cacheKey) + let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return (value, metadata) + } + + func fetchAndCacheGraphQLData( + service: SRHTService, + query: String, + variables: [String: any Sendable]?, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> CacheEntryMetadata? { + let data = try await requestCoalescer.value(for: cacheKey) { + try await self.performGraphQLRequest(service: service, query: query, variables: variables) + } + responseCache.set(data, forKey: cacheKey) + return try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + } + + func fetchAndCacheText( + url: URL, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> (String, CacheEntryMetadata?) { + let data = try await requestCoalescer.value(for: cacheKey) { + let text = try await self.fetchText(url: url) + guard let data = text.data(using: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Text could not be encoded as UTF-8")) + ) + } + return data + } + guard let text = String(data: data, encoding: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text")) + ) + } + responseCache.set(data, forKey: cacheKey) + let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return (text, metadata) + } + + static func tokenCacheScope(_ token: String) -> String { + let digest = SHA256.hash(data: Data(token.utf8)) + return digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + func throwGraphQLErrorsIfPresent(in data: Data) throws { if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), let errors = envelope.errors, @@ -632,6 +912,23 @@ private extension SRHTClient { } } +private actor RequestCoalescer { + private var tasks: [String: Task<Data, Error>] = [:] + + func value(for key: String, operation: @Sendable @escaping () async throws -> Data) async throws -> Data { + if let task = tasks[key] { + return try await task.value + } + + let task = Task { + try await operation() + } + tasks[key] = task + defer { tasks.removeValue(forKey: key) } + return try await task.value + } +} + private extension Data { mutating func append(_ string: String) { if let data = string.data(using: .utf8) { |
