// // Client.swift // Pachyderm // // Created by Shadowfacts on 9/8/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import Foundation /** The base Mastodon API client. */ public class Client { public typealias Callback = (Response) -> Void let baseURL: URL let session: URLSession public var accessToken: String? public var appID: String? public var clientID: String? public var clientSecret: String? public var timeoutInterval: TimeInterval = 60 lazy var 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") decoder.dateDecodingStrategy = .formatted(formatter) return decoder }() public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) { self.baseURL = baseURL self.accessToken = accessToken self.session = session } public func run(_ request: Request, completion: @escaping Callback) { guard let request = createURLRequest(request: request) else { completion(.failure(Error.invalidRequest)) return } let task = session.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(error)) return } guard let data = data, let response = response as? HTTPURLResponse else { completion(.failure(Error.invalidResponse)) return } guard response.statusCode == 200 else { let mastodonError = try? self.decoder.decode(MastodonError.self, from: data) let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError completion(.failure(error)) return } guard let result = try? self.decoder.decode(Result.self, from: data) else { completion(.failure(Error.invalidModel)) return } if var result = result as? ClientModel { result.client = self } else if var result = result as? [ClientModel] { result.client = self } let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init) completion(.success(result, pagination)) } task.resume() } func createURLRequest(request: Request) -> URLRequest? { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } components.path = request.path components.queryItems = request.queryParameters.queryItems guard let url = components.url else { return nil } var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval) urlRequest.httpMethod = request.method.name urlRequest.httpBody = request.body.data urlRequest.setValue(request.body.mimeType, forHTTPHeaderField: "Content-Type") if let accessToken = accessToken { urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } return urlRequest } // MARK: - Authorization public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback) { let request = Request(method: .post, path: "/api/v1/apps", body: .parameters([ "client_name" => name, "redirect_uris" => redirectURI, "scopes" => scopes.scopeString, "website" => website?.absoluteString ])) run(request) { result in defer { completion(result) } guard case let .success(application, _) = result else { return } self.appID = application.id self.clientID = application.clientID self.clientSecret = application.clientSecret } } public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback) { let request = Request(method: .post, path: "/oauth/token", body: .parameters([ "client_id" => clientID, "client_secret" => clientSecret, "grant_type" => "authorization_code", "code" => authorizationCode, "redirect_uri" => redirectURI ])) run(request) { result in defer { completion(result) } guard case let .success(loginSettings, _) = result else { return } self.accessToken = loginSettings.accessToken } } // MARK: - Self public func getSelfAccount() -> Request { return Request(method: .get, path: "/api/v1/accounts/verify_credentials") } public func getFavourites() -> Request<[Status]> { return Request<[Status]>(method: .get, path: "/api/v1/favourites") } public func getRelationships(accounts: [Account]? = nil) -> Request<[Relationship]> { return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts?.map { $0.id }) } public func getInstance() -> Request { return Request(method: .get, path: "/api/v1/instance") } public func getCustomEmoji() -> Request<[Emoji]> { return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis") } // MARK: - Accounts public func getAccount(id: String) -> Request { return Request(method: .get, path: "/api/v1/accounts/\(id)") } public func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil) -> Request<[Account]> { return Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [ "q" => query, "limit" => limit, "following" => following ]) } // MARK: - Blocks public func getBlocks() -> Request<[Account]> { return Request<[Account]>(method: .get, path: "/api/v1/blocks") } public func getDomainBlocks() -> Request<[String]> { return Request<[String]>(method: .get, path: "api/v1/domain_blocks") } public func block(domain: String) -> Request { return Request(method: .post, path: "/api/v1/domain_blocks", body: .parameters([ "domain" => domain ])) } public func unblock(domain: String) -> Request { return Request(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([ "domain" => domain ])) } // MARK: - Filters public func getFilters() -> Request<[Filter]> { return Request<[Filter]>(method: .get, path: "/api/v1/filters") } public func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request { return Request(method: .post, path: "/api/v1/filters", body: .parameters([ "phrase" => phrase, "irreversible" => irreversible, "whole_word" => wholeWord, "expires_at" => expiresAt ] + "context" => context.contextStrings)) } public func getFilter(id: String) -> Request { return Request(method: .get, path: "/api/v1/filters/\(id)") } // MARK: - Follows public func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> { var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests") request.range = range return request } public func getFollowSuggestions() -> Request<[Account]> { return Request<[Account]>(method: .get, path: "/api/v1/suggestions") } public func followRemote(acct: String) -> Request { return Request(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct])) } // MARK: - Lists public func getLists() -> Request<[List]> { return Request<[List]>(method: .get, path: "/api/v1/lists") } public func getList(id: String) -> Request { return Request(method: .get, path: "/api/v1/lists/\(id)") } public func createList(title: String) -> Request { return Request(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title])) } // MARK: - Media public func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request { return Request(method: .post, path: "/api/v1/media", body: .formData([ "description" => description, "focus" => focus ], attachment)) } // MARK: - Mutes public func getMutes(range: RequestRange) -> Request<[Account]> { var request = Request<[Account]>(method: .get, path: "/api/v1/mutes") request.range = range return request } // MARK: - Notifications public func getNotifications(range: RequestRange = .default) -> Request<[Notification]> { var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications") request.range = range return request } public func clearNotifications() -> Request { return Request(method: .post, path: "/api/v1/notifications/clear") } // MARK: - Reports public func getReports() -> Request<[Report]> { return Request<[Report]>(method: .get, path: "/api/v1/reports") } public func report(account: Account, statuses: [Status], comment: String) -> Request { return Request(method: .post, path: "/api/v1/reports", body: .parameters([ "account_id" => account.id, "comment" => comment ] + "status_ids" => statuses.map { $0.id })) } // MARK: - Search public func search(query: String, resolve: Bool? = nil) -> Request { return Request(method: .get, path: "/api/v2/search", queryParameters: [ "q" => query, "resolve" => resolve ]) } // MARK: - Statuses public func getStatus(id: String) -> Request { return Request(method: .get, path: "/api/v1/statuses/\(id)") } public func createStatus(text: String, inReplyTo: String? = nil, media: [Attachment]? = nil, sensitive: Bool? = nil, spoilerText: String? = nil, visiblity: Status.Visibility? = nil, language: String? = nil) -> Request { return Request(method: .post, path: "/api/v1/statuses", body: .parameters([ "status" => text, "in_reply_to_id" => inReplyTo, "sensitive" => sensitive, "spoiler_text" => spoilerText, "visibility" => visiblity?.rawValue, "language" => language ] + "media_ids" => media?.map { $0.id })) } // MARK: - Timelines public func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> { return timeline.request(range: range) } } extension Client { public enum Error: Swift.Error { case unknownError case invalidRequest case invalidResponse case invalidModel case mastodonError(String) } }