summaryrefslogtreecommitdiff
path: root/Hutch/Networking
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Networking')
-rw-r--r--Hutch/Networking/APICache.swift337
-rw-r--r--Hutch/Networking/APICacheKeys.swift105
-rw-r--r--Hutch/Networking/SRHTClient.swift299
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) {