summaryrefslogtreecommitdiff
path: root/Hutch/Networking/HutchStatsService.swift
blob: fbac5be5ee620853c340361ebca45136f05e49ce (plain) (blame)
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
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
    private let currentActor: String?

    init(
        session: URLSession = .shared,
        configuration: AppConfiguration,
        currentActor: String? = nil
    ) {
        self.session = session
        self.baseURL = configuration.hutchStatsBaseURL
        self.decoder = JSONDecoder()
        self.currentActor = currentActor
    }

    func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse {
        return try await fetch(
            path: "api/contributions/\(actor)",
            queryItems: contributionQueryItems(actor: actor, endingOn: endDate),
            responseType: ContributionCalendarResponse.self
        )
    }

    func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse {
        return try await fetch(
            path: "api/contributions/\(actor)/stats",
            queryItems: contributionQueryItems(actor: actor, 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)
        }

        var request = URLRequest(url: url)
        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")

        let (data, response): (Data, URLResponse)
        do {
            (data, response) = try await session.data(for: request)
        } catch {
            throw SRHTError.networkError(error)
        }

        if let httpResponse = response as? HTTPURLResponse,
           !(200...299).contains(httpResponse.statusCode) {
            throw SRHTError.httpError(httpResponse.statusCode)
        }

        do {
            return try decoder.decode(responseType, from: data)
        } catch {
            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
    }

    func contributionQueryItems(actor: String, 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)

        var queryItems = [
            URLQueryItem(name: "from", value: start),
            URLQueryItem(name: "to", value: end)
        ]

        if actor == currentActor {
            queryItems.append(URLQueryItem(name: "prioritize", value: "self"))
        }

        return queryItems
    }

    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
    }()

}