diff options
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) { |
