// // MastodonAPI.swift // elpha-ios // // Created by Dwayne Harris on 10/17/18. // Copyright © 2018 Elpha. All rights reserved. // import Alamofire import Foundation typealias JSONObject = [String: Any] typealias JSONArray = [Any] typealias JSONObjectArray = [JSONObject] enum MastodonRequestError: Error { case noResponse, unauthenticated } enum TimelineCategory: String { case home, local, federated, tag, favorites } enum PaginationDirection: String { case prev, next } enum AttachmentType: String { case unknown, image, gifv, video } enum StatusVisibility: String, CaseIterable { case `public`, unlisted, `private`, direct } public class PaginationItem: NSObject, NSCoding { let direction: PaginationDirection let statusID: String init(direction: PaginationDirection, statusID: String) { self.direction = direction self.statusID = statusID } public func encode(with aCoder: NSCoder) { aCoder.encode(direction.rawValue, forKey: "direction") aCoder.encode(statusID, forKey: "statusID") } convenience required public init?(coder aDecoder: NSCoder) { guard let direction = PaginationDirection(rawValue: aDecoder.decodeObject(forKey: "direction") as! String), let statusID = aDecoder.decodeObject(forKey: "statusID") as? String else { return nil } self.init(direction: direction, statusID: statusID) } } class MastodonAPI { static var serverURL: URL? { guard let host = AuthenticationManager.session?.client?.host else { return nil } return URL(string: "https://\(host)") } static func parseLinkHeader(_ link: String?) -> [PaginationItem] { guard let link = link else { return [] } let regex = try! NSRegularExpression(pattern: "<[\\S]+(?:max_id|since_id|min_id)=([0-9]+)[\\S]*>; rel=\"(next|prev)\"", options: .caseInsensitive) let matches = regex.matches(in: link, options: [], range: NSRange(location: 0, length: link.count)) return matches.map { match in let statusRange = match.range(at: 1) let directionRange = match.range(at: 2) let statusID = link[Range(statusRange, in: link)!] let direction = link[Range(directionRange, in: link)!] return PaginationItem(direction: PaginationDirection(rawValue: String(direction))!, statusID: String(statusID)) } } private static func request( token: String, serverURL: URL, path: String, method: HTTPMethod = .get, parameters: Parameters? = nil, pagination: PaginationItem? = nil, completion: @escaping (Any?, [PaginationItem]?, Error?) -> Void ) { let requestURL = serverURL.appendingPathComponent(path) let headers: HTTPHeaders = [ "Authorization": "Bearer \(token)", "Accept": "application/json", ] if Config.logRequests { print("Request: \(requestURL.absoluteString)") } var parameters = parameters ?? [:] if let pagination = pagination { switch pagination.direction { case .prev: parameters["min_id"] = pagination.statusID case .next: parameters["max_id"] = pagination.statusID } } Alamofire.request( requestURL, method: method, parameters: parameters, encoding: URLEncoding.default, headers: headers ).validate().responseJSON { response in switch response.result { case .success(let data): let pagination = self.parseLinkHeader(response.response?.allHeaderFields["Link"] as? String) completion(data, pagination, nil) case .failure(let error): completion(nil, nil, error) } } } private static func request( path: String, method: HTTPMethod = .get, parameters: Parameters? = nil, pagination: PaginationItem? = nil, completion: @escaping (Any?, [PaginationItem]?, Error?) -> Void ) { guard let token = AuthenticationManager.token, let serverURL = self.serverURL else { completion(nil, nil, MastodonRequestError.unauthenticated) return } request( token: token, serverURL: serverURL, path: path, method: method, parameters: parameters, pagination: pagination, completion: completion ) } static func currentUser(token: String, serverURL: URL, completion: @escaping (JSONObject?, Error?) -> Void) { self.request(token: token, serverURL: serverURL, path: "api/v1/accounts/verify_credentials") { data, _, error in guard error == nil else { completion(nil, error) return } completion(data as? JSONObject, nil) } } static func account(id: String, completion: @escaping (JSONObject?, Error?) -> Void) { self.request(path: "api/v1/accounts/\(id)") { data, _, error in guard error == nil else { completion(nil, error) return } completion(data as? JSONObject, nil) } } static func status(id: String, completion: @escaping (JSONObject?, Error?) -> Void) { self.request(path: "api/v1/statuses/\(id)") { data, _, error in guard error == nil else { completion(nil, error) return } completion(data as? JSONObject, nil) } } static func context(id: String, completion: @escaping ([String: Any]?, Error?) -> Void) { self.request(path: "api/v1/statuses/\(id)/context") { data, _, error in guard error == nil else { completion(nil, error) return } completion(data as? [String: Any], nil) } } static func homeTimeline( limit: Int?, pagination: PaginationItem?, completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void ) { let parameters: Parameters = ["limit": limit ?? 20] self.request(path: "api/v1/timelines/home", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in guard error == nil else { completion(nil, nil, error) return } completion(data as? JSONObjectArray, pagination, nil) } } static func publicTimeline( local: Bool, limit: Int?, pagination: PaginationItem?, completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void ) { let parameters: Parameters = [ "local": local, "limit": limit ?? 20, ] self.request(path: "api/v1/timelines/public", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in guard error == nil else { completion(nil, nil, error) return } completion(data as? JSONObjectArray, pagination, nil) } } static func tagTimeline( tag: String, local: Bool, limit: Int?, pagination: PaginationItem?, completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void ) { let parameters: Parameters = [ "local": local, "limit": limit ?? 20, ] self.request(path: "api/v1/timelines/tag/\(tag)", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in guard error == nil else { completion(nil, nil, error) return } completion(data as? JSONObjectArray, pagination, nil) } } static func statuses( accountID: String, onlyMedia: Bool, excludeReplies: Bool, limit: Int?, pagination: PaginationItem?, completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void ) { let parameters: Parameters = [ "only_media": onlyMedia, "exclude_replies": excludeReplies, "limit": limit ?? 20, ] self.request(path: "api/v1/accounts/\(accountID)/statuses", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in guard error == nil else { completion(nil, nil, error) return } completion(data as? JSONObjectArray, pagination, nil) } } static func pinnedStatuses( accountID: String, limit: Int?, completion: @escaping (JSONObjectArray?, Error?) -> Void ) { let parameters: Parameters = [ "only_media": false, "exclude_replies": false, "pinned": true, "limit": limit ?? 20, ] self.request(path: "api/v1/accounts/\(accountID)/statuses", method: .get, parameters: parameters) { data, pagination, error in guard error == nil else { completion(nil, error) return } completion(data as? JSONObjectArray, nil) } } static func favorites( limit: Int?, pagination: PaginationItem?, completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void ) { let parameters: Parameters = ["limit": limit ?? 20] self.request(path: "api/v1/favourites", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in guard error == nil else { completion(nil, nil, error) return } completion(data as? JSONObjectArray, pagination, nil) } } static func reblog(statusID: String, completion: @escaping (Error?) -> Void) { self.request(path: "api/v1/statuses/\(statusID)/reblog", method: .post) { _, _, error in guard error == nil else { completion(error) return } completion(nil) } } static func unreblog(statusID: String, completion: @escaping (Error?) -> Void) { self.request(path: "api/v1/statuses/\(statusID)/unreblog", method: .post) { _, _, error in guard error == nil else { completion(error) return } completion(nil) } } static func favorite(statusID: String, completion: @escaping (Error?) -> Void) { self.request(path: "api/v1/statuses/\(statusID)/favourite", method: .post) { _, _, error in guard error == nil else { completion(error) return } completion(nil) } } static func unfavorite(statusID: String, completion: @escaping (Error?) -> Void) { self.request(path: "api/v1/statuses/\(statusID)/unfavourite", method: .post) { _, _, error in guard error == nil else { completion(error) return } completion(nil) } } static func postStatus(content: String, replyToID: String?, spoilerText: String?, visibility: StatusVisibility = .public, completion: @escaping (Error?) -> Void) { var parameters: Parameters = [ "status": content, "visibility": visibility.rawValue, ] if let replyToID = replyToID { parameters["in_reply_to_id"] = replyToID } if let spoilerText = spoilerText { parameters["spoiler_text"] = spoilerText } self.request(path: "api/v1/statuses", method: .post, parameters: parameters) { _, _, error in guard error == nil else { completion(error) return } completion(nil) } } static func search(content: String, completion: @escaping (JSONObject?, Error?) -> Void) { let parameters: Parameters = ["q": content] self.request(path: "api/v2/search", method: .get, parameters: parameters) { data, _, error in guard error == nil else { completion(nil, error) return } completion(data as? JSONObject, nil) } } static func notifications( limit: Int?, pagination: PaginationItem?, completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void ) { let parameters: Parameters = ["limit": limit ?? 20] self.request(path: "api/v1/notifications", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in guard error == nil else { completion(nil, nil, error) return } completion(data as? JSONObjectArray, pagination, nil) } } static func registerApp(serverURL: URL, completion: @escaping (JSONObject?, Error?) -> Void) { let requestURL = serverURL.appendingPathComponent("api/v1/apps") let parameters: Parameters = [ "client_name": Config.clientDisplayName, "redirect_uris": Config.clientRedirectURI, "scopes": "read write follow", "website": Config.clientWebsite, ] Alamofire.request( requestURL, method: .post, parameters: parameters, encoding: URLEncoding(destination: .httpBody) ).validate().responseJSON { response in switch response.result { case .success(let data): completion(data as? JSONObject, nil) case .failure(let error): completion(nil, error) } } } }