krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.1: Hutch/Networking/HutchStatsService.swift · raw
1import Foundation
2
3protocol ContributionCalendarServing: Sendable {
4 func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse
5 func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse
6}
7
8struct HutchStatsService: ContributionCalendarServing {
9 private let session: URLSession
10 private let decoder: JSONDecoder
11 private let baseURL: URL
12
13 init(
14 session: URLSession = .shared,
15 configuration: AppConfiguration
16 ) {
17 self.session = session
18 self.baseURL = configuration.hutchStatsBaseURL
19 self.decoder = JSONDecoder()
20 }
21
22 func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse {
23 debugLog("calendar request actor=\(actor) endDate=\(Self.rangeFormatter.string(from: endDate))")
24 return try await fetch(
25 path: "api/contributions/\(actor)",
26 queryItems: trailingYearQueryItems(endingOn: endDate),
27 responseType: ContributionCalendarResponse.self
28 )
29 }
30
31 func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse {
32 debugLog("stats request actor=\(actor) endDate=\(Self.rangeFormatter.string(from: endDate))")
33 return try await fetch(
34 path: "api/contributions/\(actor)/stats",
35 queryItems: trailingYearQueryItems(endingOn: endDate),
36 responseType: ContributionStatsResponse.self
37 )
38 }
39
40 private func fetch<Response: Decodable>(
41 path: String,
42 queryItems: [URLQueryItem],
43 responseType: Response.Type
44 ) async throws -> Response {
45 guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
46 throw URLError(.badURL)
47 }
48
49 components.path = normalizedPath(basePath: components.path, appendedPath: path)
50 components.queryItems = queryItems
51
52 guard let url = components.url else {
53 throw URLError(.badURL)
54 }
55
56 debugLog("request \(url.absoluteString)")
57 let (data, response): (Data, URLResponse)
58 do {
59 (data, response) = try await session.data(from: url)
60 } catch {
61 debugLog("network failure \(url.absoluteString) error=\(error.localizedDescription)")
62 throw SRHTError.networkError(error)
63 }
64
65 if let httpResponse = response as? HTTPURLResponse {
66 debugLog("response \(url.absoluteString) status=\(httpResponse.statusCode) bytes=\(data.count)")
67 if !(200...299).contains(httpResponse.statusCode) {
68 throw SRHTError.httpError(httpResponse.statusCode)
69 }
70 }
71
72 do {
73 return try decoder.decode(responseType, from: data)
74 } catch {
75 let preview = String(decoding: data.prefix(300), as: UTF8.self)
76 debugLog("decode failure \(url.absoluteString) error=\(error.localizedDescription) body=\(preview)")
77 throw SRHTError.decodingError(error)
78 }
79 }
80
81 private func normalizedPath(basePath: String, appendedPath: String) -> String {
82 let trimmedBase = basePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
83 let trimmedAppendix = appendedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
84
85 let pathComponents = [trimmedBase, trimmedAppendix].filter { !$0.isEmpty }
86 return "/" + pathComponents.joined(separator: "/")
87 }
88
89 func trailingRange(endingOn endDate: Date) -> ClosedRange<Date> {
90 let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate)
91 let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate
92 let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack
93 return normalizedStartDate...normalizedEndDate
94 }
95
96 private func trailingYearQueryItems(endingOn endDate: Date) -> [URLQueryItem] {
97 let range = trailingRange(endingOn: endDate)
98 let start = Self.rangeFormatter.string(from: range.lowerBound)
99 let end = Self.rangeFormatter.string(from: range.upperBound)
100
101 return [
102 URLQueryItem(name: "from", value: start),
103 URLQueryItem(name: "to", value: end)
104 ]
105 }
106
107 private static let rangeFormatter: DateFormatter = {
108 let formatter = DateFormatter()
109 formatter.calendar = .contributionCalendar
110 formatter.locale = Locale(identifier: "en_US_POSIX")
111 formatter.timeZone = TimeZone(secondsFromGMT: 0)
112 formatter.dateFormat = "yyyy-MM-dd"
113 return formatter
114 }()
115
116 private func debugLog(_ message: String) {
117#if DEBUG
118 print("[HutchStatsService] \(message)")
119#endif
120 }
121}