347 lines
14 KiB
Swift
347 lines
14 KiB
Swift
|
//
|
||
|
// 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<Result: Decodable> = (Response<Result>) -> 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
|
||
|
}
|
||
|
|
||
|
// MARK: - Internal Helpers
|
||
|
func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) {
|
||
|
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<Result>(request: Request<Result>) -> 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<RegisteredApplication>) {
|
||
|
let request = Request<RegisteredApplication>(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<LoginSettings>) {
|
||
|
let request = Request<LoginSettings>(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(completion: @escaping Callback<Account>) {
|
||
|
let request = Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func getFavourites(completion: @escaping Callback<[Status]>) {
|
||
|
let request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func getRelationships(accounts: [Account]? = nil, completion: @escaping Callback<[Relationship]>) {
|
||
|
let request = Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts?.map { $0.id })
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func getInstance(completion: @escaping Callback<Instance>) {
|
||
|
let request = Request<Instance>(method: .get, path: "/api/v1/instance")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func getCustomEmoji(completion: @escaping Callback<[Emoji]>) {
|
||
|
let request = Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Accounts
|
||
|
public func getAccount(id: String, completion: @escaping Callback<Account>) {
|
||
|
let request = Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil, completion: @escaping Callback<[Account]>) {
|
||
|
let request = Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
|
||
|
"q" => query,
|
||
|
"limit" => limit,
|
||
|
"following" => following
|
||
|
])
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Blocks
|
||
|
public func getBlocks(completion: @escaping Callback<[Account]>) {
|
||
|
let request = Request<[Account]>(method: .get, path: "/api/v1/blocks")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func getDomainBlocks(completion: @escaping Callback<[String]>) {
|
||
|
let request = Request<[String]>(method: .get, path: "api/v1/domain_blocks")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func block(domain: String, completion: @escaping Callback<Empty>) {
|
||
|
let request = Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
|
||
|
"domain" => domain
|
||
|
]))
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func unblock(domain: String, completion: @escaping Callback<Empty>) {
|
||
|
let request = Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
|
||
|
"domain" => domain
|
||
|
]))
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Filters
|
||
|
public func getFilters(completion: @escaping Callback<[Filter]>) {
|
||
|
let request = Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil, completion: @escaping Callback<Filter>) {
|
||
|
let request = Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
|
||
|
"phrase" => phrase,
|
||
|
"irreversible" => irreversible,
|
||
|
"whole_word" => wholeWord,
|
||
|
"expires_at" => expiresAt
|
||
|
] + "context" => context.contextStrings))
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func getFilter(id: String, completion: @escaping Callback<Filter>) {
|
||
|
let request = Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Follows
|
||
|
public func getFollowRequests(range: RequestRange = .default, completion: @escaping Callback<[Account]>) {
|
||
|
var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
|
||
|
request.range = range
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func getFollowSuggestions(completion: @escaping Callback<[Account]>) {
|
||
|
let request = Request<[Account]>(method: .get, path: "/api/v1/suggestions")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func followRemote(acct: String, completion: @escaping Callback<Account>) {
|
||
|
let request = Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Lists
|
||
|
public func getLists(completion: @escaping Callback<[List]>) {
|
||
|
let request = Request<[List]>(method: .get, path: "/api/v1/lists")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func getList(id: String, completion: @escaping Callback<List>) {
|
||
|
let request = Request<List>(method: .get, path: "/api/v1/lists/\(id)")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func createList(title: String, completion: @escaping Callback<List>) {
|
||
|
let request = Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Media
|
||
|
public func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil, completion: @escaping Callback<Attachment>) {
|
||
|
let request = Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
|
||
|
"description" => description,
|
||
|
"focus" => focus
|
||
|
], attachment))
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Mutes
|
||
|
public func getMutes(range: RequestRange, completion: @escaping Callback<[Account]>) {
|
||
|
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
||
|
request.range = range
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Notifications
|
||
|
public func getNotifications(range: RequestRange = .default, completion: @escaping Callback<[Notification]>) {
|
||
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications")
|
||
|
request.range = range
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func clearNotifications(completion: @escaping Callback<Empty>) {
|
||
|
let request = Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Reports
|
||
|
public func getReports(completion: @escaping Callback<[Report]>) {
|
||
|
let request = Request<[Report]>(method: .get, path: "/api/v1/reports")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func report(account: Account, statuses: [Status], comment: String, completion: @escaping Callback<Report>) {
|
||
|
let request = Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
|
||
|
"account_id" => account.id,
|
||
|
"comment" => comment
|
||
|
] + "status_ids" => statuses.map { $0.id }))
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Search
|
||
|
public func search(query: String, resolve: Bool? = nil, completion: @escaping Callback<SearchResults>) {
|
||
|
let request = Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
||
|
"q" => query,
|
||
|
"resolve" => resolve
|
||
|
])
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Statuses
|
||
|
public func getStatus(id: String, completion: @escaping Callback<Status>) {
|
||
|
let request = Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
public func createStatus(text: String,
|
||
|
inReplyTo: Status? = nil,
|
||
|
media: [Attachment]? = nil,
|
||
|
sensitive: Bool? = nil,
|
||
|
spoilerText: String? = nil,
|
||
|
visiblity: Status.Visibility? = nil,
|
||
|
language: String? = nil,
|
||
|
completion: @escaping Callback<Status>) {
|
||
|
let request = Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
|
||
|
"status" => text,
|
||
|
"in_reply_to_id" => inReplyTo?.id,
|
||
|
"sensitive" => sensitive,
|
||
|
"spoiler_text" => spoilerText,
|
||
|
"visibility" => visiblity?.rawValue,
|
||
|
"language" => language
|
||
|
] + "media" => media?.map { $0.id }))
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
// MARK: - Timelines
|
||
|
public func getStatuses(timeline: Timeline, range: RequestRange = .default, completion: @escaping Callback<[Status]>) {
|
||
|
let request = timeline.request(range: range)
|
||
|
run(request, completion: completion)
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
extension Client {
|
||
|
public enum Error: Swift.Error {
|
||
|
case unknownError
|
||
|
case invalidRequest
|
||
|
case invalidResponse
|
||
|
case invalidModel
|
||
|
case mastodonError(String)
|
||
|
|
||
|
}
|
||
|
}
|