krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.8.2: 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        var request = URLRequest(url: url)
 58        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
 59
 60        let (data, response): (Data, URLResponse)
 61        do {
 62            (data, response) = try await session.data(for: request)
 63        } catch {
 64            throw SRHTError.networkError(error)
 65        }
 66
 67        if let httpResponse = response as? HTTPURLResponse,
 68           !(200...299).contains(httpResponse.statusCode) {
 69            throw SRHTError.httpError(httpResponse.statusCode)
 70        }
 71
 72        do {
 73            return try decoder.decode(responseType, from: data)
 74        } catch {
 75            throw SRHTError.decodingError(error)
 76        }
 77    }
 78
 79    private func normalizedPath(basePath: String, appendedPath: String) -> String {
 80        let trimmedBase = basePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
 81        let trimmedAppendix = appendedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
 82
 83        let pathComponents = [trimmedBase, trimmedAppendix].filter { !$0.isEmpty }
 84        return "/" + pathComponents.joined(separator: "/")
 85    }
 86
 87    func trailingRange(endingOn endDate: Date) -> ClosedRange<Date> {
 88        let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate)
 89        let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate
 90        let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack
 91        return normalizedStartDate...normalizedEndDate
 92    }
 93
 94    func contributionQueryItems(actor: String, endingOn endDate: Date) -> [URLQueryItem] {
 95        let range = trailingRange(endingOn: endDate)
 96        let start = Self.rangeFormatter.string(from: range.lowerBound)
 97        let end = Self.rangeFormatter.string(from: range.upperBound)
 98
 99        var queryItems = [
100            URLQueryItem(name: "from", value: start),
101            URLQueryItem(name: "to", value: end)
102        ]
103
104        if actor == currentActor {
105            queryItems.append(URLQueryItem(name: "prioritize", value: "self"))
106        }
107
108        return queryItems
109    }
110
111    private static let rangeFormatter: DateFormatter = {
112        let formatter = DateFormatter()
113        formatter.calendar = .contributionCalendar
114        formatter.locale = Locale(identifier: "en_US_POSIX")
115        formatter.timeZone = TimeZone(secondsFromGMT: 0)
116        formatter.dateFormat = "yyyy-MM-dd"
117        return formatter
118    }()
119
120}