krz/hutch

an ios client for sourcehut

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

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