// // FervorClient.swift // Fervor // // Created by Shadowfacts on 11/25/21. // @preconcurrency import Foundation public actor FervorClient: Sendable { private let instanceURL: URL private let session: URLSession public private(set) var accessToken: String? private let decoder: JSONDecoder = { let d = JSONDecoder() let withFractionalSeconds = ISO8601DateFormatter() withFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let without = ISO8601DateFormatter() without.formatOptions = [.withInternetDateTime] // because fucking ISO8601DateFormatter isn't a DateFormatter d.dateDecodingStrategy = .custom({ decoder in let s = try decoder.singleValueContainer().decode(String.self) // try both because Elixir's DateTime.to_iso8601 omits the .0 if the date doesn't have fractional seconds if let d = withFractionalSeconds.date(from: s) { return d } else if let d = without.date(from: s) { return d } else { throw DateDecodingError() } }) return d }() public init(instanceURL: URL, accessToken: String?, session: URLSession = .shared) { self.instanceURL = instanceURL self.accessToken = accessToken self.session = session } private func buildURL(path: String, queryItems: [URLQueryItem] = []) -> URL { var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)! components.path = path components.queryItems = queryItems return components.url! } private func performRequest(_ request: URLRequest) async throws -> T { var request = request if let accessToken = accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } let (data, response) = try await session.data(for: request, delegate: nil) if (response as! HTTPURLResponse).statusCode == 404 { throw Error.notFound } let decoded = try decoder.decode(T.self, from: data) return decoded } public func register(clientName: String, website: URL? = nil, redirectURI: URL) async throws -> ClientRegistration { var request = URLRequest(url: buildURL(path: "/api/v1/register")) request.httpMethod = "POST" request.setURLEncodedBody(params: [ "client_name": clientName, "website": website?.absoluteString, "redirect_uri": redirectURI.absoluteString, ]) return try await performRequest(request) } public func token(authCode: String, redirectURI: URL, clientID: String, clientSecret: String) async throws -> Token { var request = URLRequest(url: buildURL(path: "/oauth/token")) request.httpMethod = "POST" request.setURLEncodedBody(params: [ "grant_type": "authorization_code", "authorization_code": authCode, "redirect_uri": redirectURI.absoluteString, "client_id": clientID, "client_secret": clientSecret, ]) let result: Token = try await performRequest(request) self.accessToken = result.accessToken return result } public func groups() async throws -> [Group] { let request = URLRequest(url: buildURL(path: "/api/v1/groups")) return try await performRequest(request) } public func feeds() async throws -> [Feed] { let request = URLRequest(url: buildURL(path: "/api/v1/feeds")) return try await performRequest(request) } public func syncItems(lastSync: Date?) async throws -> ItemsSyncUpdate { let request = URLRequest(url: buildURL(path: "/api/v1/items/sync", queryItems: [ URLQueryItem(name: "last_sync", value: lastSync?.formatted(.iso8601)) ])) return try await performRequest(request) } public func items(feed id: FervorID) async throws -> [Item] { let request = URLRequest(url: buildURL(path: "/api/v1/feeds/\(id)/items")) return try await performRequest(request) } public func item(id: FervorID) async throws -> Item? { let request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)")) do { return try await performRequest(request) } catch { if let error = error as? Error, case .notFound = error { return nil } else { throw error } } } public func read(item id: FervorID) async throws -> Item { var request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)/read")) request.httpMethod = "POST" return try await performRequest(request) } public func unread(item id: FervorID) async throws -> Item { var request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)/unread")) request.httpMethod = "POST" return try await performRequest(request) } public func read(ids: [FervorID]) async throws -> [FervorID] { var request = URLRequest(url: buildURL(path: "/api/v1/items/read")) request.httpMethod = "POST" request.setURLEncodedBody(params: ["ids": ids.joined(separator: ",")]) return try await performRequest(request) } public func unread(ids: [FervorID]) async throws -> [FervorID] { var request = URLRequest(url: buildURL(path: "/api/v1/items/unread")) request.httpMethod = "POST" request.setURLEncodedBody(params: ["ids": ids.joined(separator: ",")]) return try await performRequest(request) } public struct Auth { public let accessToken: String public let refreshToken: String? } public enum Error: Swift.Error { case urlSession(Swift.Error) case decode(Swift.Error) case notFound } } private struct DateDecodingError: Error { }