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