// // APIController.swift // MastoSearch // // Created by Shadowfacts on 2/19/22. // import Cocoa struct APIController { static let shared = APIController() let scopes = "read" let redirectScheme = "mastosearch" let redirectURI = "mastosearch://oauth" private let decoder: JSONDecoder = { let decoder = JSONDecoder() let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ" formatter.timeZone = TimeZone(abbreviation: "UTC") formatter.locale = Locale(identifier: "en_US_POSIX") let iso8601 = ISO8601DateFormatter() decoder.dateDecodingStrategy = .custom({ (decoder) in let container = try decoder.singleValueContainer() let str = try container.decode(String.self) // for the next time mastodon accidentally changes date formats >.> if let date = formatter.date(from: str) { return date } else if let date = iso8601.date(from: str) { return date } else { throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format")) } }) return decoder }() private init() {} private func run(request: URLRequest, completion: @escaping (Result) -> Void) { var request = request if let accessToken = LocalData.account?.accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(.error(error))) return } let response = response as! HTTPURLResponse guard response.statusCode == 200 else { completion(.failure(.unexpectedStatusCode(response.statusCode))) return } guard let data = data else { completion(.failure(.noData)) return } do { let statuses = try decoder.decode(R.self, from: data) completion(.success(statuses)) } catch { completion(.failure(.decoding(error))) return } } task.resume() } func register(completion: @escaping (Result) -> Void) { guard let account = LocalData.account else { return } var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)! components.path = "/api/v1/apps" var req = URLRequest(url: components.url!) req.httpMethod = "POST" req.httpBody = [ ("client_name", "MastoSearch"), ("redirect_uris", redirectURI), ("scopes", scopes), ].map { "\($0.0)=\($0.1)" }.joined(separator: "&").data(using: .utf8)! run(request: req, completion: completion) } func getAccessToken(authCode: String, completion: @escaping (Result) -> Void) { guard let account = LocalData.account else { return } var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)! components.path = "/oauth/token" var req = URLRequest(url: components.url!) req.httpMethod = "POST" req.httpBody = [ ("client_id", account.clientID!), ("client_secret", account.clientSecret!), ("grant_type", "authorization_code"), ("code", authCode), ("redirect_uri", redirectURI), ].map { "\($0.0)=\($0.1)" }.joined(separator: "&").data(using: .utf8)! run(request: req, completion: completion) } func getStatuses(range: RequestRange, completion: @escaping (Result<[Status], Error>) -> Void) { guard let account = LocalData.account else { return } var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)! components.path = "/api/v1/accounts/1/statuses" components.queryItems = range.queryParameters + [ URLQueryItem(name: "exclude_replies", value: "false"), ] run(request: URLRequest(url: components.url!), completion: completion) } } extension APIController { enum RequestRange { case `default` case after(String) var queryParameters: [URLQueryItem] { switch self { case .default: return [] case .after(let id): return [URLQueryItem(name: "min_id", value: id)] } } } struct ClientRegistration: Decodable { let client_id: String let client_secret: String } struct LoginSettings: Decodable { let access_token: String } struct Status: Decodable { let id: String let url: String let spoiler_text: String let content: String let created_at: Date let hasReblog: Bool init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) self.url = try container.decode(String.self, forKey: .url) self.spoiler_text = try container.decode(String.self, forKey: .spoiler_text) self.content = try container.decode(String.self, forKey: .content) self.created_at = try container.decode(Date.self, forKey: .created_at) if container.contains(.reblog) { self.hasReblog = !(try container.decodeNil(forKey: .reblog)) } else { self.hasReblog = false } } enum CodingKeys: String, CodingKey { case id, url, spoiler_text, content, created_at, reblog } } } extension APIController { enum Error: Swift.Error { case unexpectedStatusCode(Int) case error(Swift.Error) case noData case decoding(Swift.Error) var localizedDescription: String { switch self { case .unexpectedStatusCode(let code): return "Unexpected status code \(code)" case .error(let inner): return inner.localizedDescription case .noData: return "No data" case .decoding(let error): return "Decoding: \(error.localizedDescription)" } } } }