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