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