forked from shadowfacts/Tusker
573 lines
22 KiB
Swift
573 lines
22 KiB
Swift
//
|
|
// Client.swift
|
|
// Pachyderm
|
|
//
|
|
// Created by Shadowfacts on 9/8/18.
|
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import WebURL
|
|
|
|
/**
|
|
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
|
|
|
|
static 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
|
|
}()
|
|
|
|
static let encoder: JSONEncoder = {
|
|
let encoder = JSONEncoder()
|
|
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")
|
|
encoder.dateEncodingStrategy = .formatted(formatter)
|
|
return encoder
|
|
}()
|
|
|
|
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
|
self.baseURL = baseURL
|
|
self.accessToken = accessToken
|
|
self.session = session
|
|
}
|
|
|
|
@discardableResult
|
|
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
|
|
guard let urlRequest = createURLRequest(request: request) else {
|
|
completion(.failure(Error(request: request, type: .invalidRequest)))
|
|
return nil
|
|
}
|
|
|
|
let task = session.dataTask(with: urlRequest) { data, response, error in
|
|
if let error = error {
|
|
completion(.failure(Error(request: request, type: .networkError(error))))
|
|
return
|
|
}
|
|
guard let data = data,
|
|
let response = response as? HTTPURLResponse else {
|
|
completion(.failure(Error(request: request, type: .invalidResponse)))
|
|
return
|
|
}
|
|
guard response.statusCode == 200 || request.additionalAcceptableHTTPCodes.contains(response.statusCode) else {
|
|
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
|
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
|
|
completion(.failure(Error(request: request, type: type)))
|
|
return
|
|
}
|
|
let result: Result
|
|
do {
|
|
result = try Client.decoder.decode(Result.self, from: data)
|
|
} catch {
|
|
completion(.failure(Error(request: request, type: .invalidModel(error))))
|
|
return
|
|
}
|
|
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
|
|
|
completion(.success(result, pagination))
|
|
}
|
|
task.resume()
|
|
return task
|
|
}
|
|
|
|
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
|
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
|
components.path = request.endpoint.path
|
|
components.queryItems = request.queryParameters.isEmpty ? nil : 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
|
|
if let mimeType = request.body.mimeType {
|
|
urlRequest.setValue(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: ParametersBody([
|
|
"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, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
|
|
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
|
|
"client_id" => clientID,
|
|
"client_secret" => clientSecret,
|
|
"grant_type" => "authorization_code",
|
|
"code" => authorizationCode,
|
|
"redirect_uri" => redirectURI,
|
|
"scope" => scopes.scopeString,
|
|
]))
|
|
run(request) { result in
|
|
defer { completion(result) }
|
|
guard case let .success(loginSettings, _) = result else { return }
|
|
|
|
self.accessToken = loginSettings.accessToken
|
|
}
|
|
}
|
|
|
|
public func revokeAccessToken() async throws {
|
|
guard let accessToken else {
|
|
return
|
|
}
|
|
let request = Request<Empty>(method: .post, path: "/oauth/revoke", body: ParametersBody([
|
|
"token" => accessToken,
|
|
"client_id" => clientID!,
|
|
"client_secret" => clientSecret!,
|
|
]))
|
|
return try await withCheckedThrowingContinuation({ continuation in
|
|
self.run(request) { response in
|
|
switch response {
|
|
case .failure(let error):
|
|
continuation.resume(throwing: error)
|
|
case .success(_, _):
|
|
continuation.resume()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
|
|
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
|
run(wellKnown) { result in
|
|
switch result {
|
|
case let .failure(error):
|
|
completion(.failure(error))
|
|
|
|
case let .success(wellKnown, _):
|
|
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
|
let href = WebURL(url.href),
|
|
href.host == WebURL(self.baseURL)?.host {
|
|
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
|
self.run(nodeInfo, completion: completion)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Self
|
|
public static func getSelfAccount() -> Request<Account> {
|
|
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
|
}
|
|
|
|
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
|
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
|
request.range = range
|
|
return request
|
|
}
|
|
|
|
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
|
|
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
|
}
|
|
|
|
public static func getInstance() -> Request<Instance> {
|
|
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
|
}
|
|
|
|
public static func getCustomEmoji() -> Request<[Emoji]> {
|
|
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
|
}
|
|
|
|
// MARK: - Accounts
|
|
public static func getAccount(id: String) -> Request<Account> {
|
|
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
|
}
|
|
|
|
public static 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 static func getBlocks() -> Request<[Account]> {
|
|
return Request<[Account]>(method: .get, path: "/api/v1/blocks")
|
|
}
|
|
|
|
public static func getDomainBlocks() -> Request<[String]> {
|
|
return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
|
|
}
|
|
|
|
public static func block(domain: String) -> Request<Empty> {
|
|
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
|
|
"domain" => domain
|
|
]))
|
|
}
|
|
|
|
public static func unblock(domain: String) -> Request<Empty> {
|
|
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
|
|
"domain" => domain
|
|
]))
|
|
}
|
|
|
|
// MARK: - Filters
|
|
public static func getFiltersV1() -> Request<[FilterV1]> {
|
|
return Request<[FilterV1]>(method: .get, path: "/api/v1/filters")
|
|
}
|
|
|
|
public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresIn: TimeInterval? = nil) -> Request<FilterV1> {
|
|
return Request<FilterV1>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
|
"phrase" => phrase,
|
|
"irreversible" => irreversible,
|
|
"whole_word" => wholeWord,
|
|
"expires_in" => expiresIn,
|
|
] + "context" => context.contextStrings))
|
|
}
|
|
|
|
public static func getFilterV1(id: String) -> Request<FilterV1> {
|
|
return Request<FilterV1>(method: .get, path: "/api/v1/filters/\(id)")
|
|
}
|
|
|
|
public static func getFiltersV2() -> Request<[FilterV2]> {
|
|
return Request(method: .get, path: "/api/v2/filters")
|
|
}
|
|
|
|
// MARK: - Follows
|
|
public static func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
|
|
var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
|
|
request.range = range
|
|
return request
|
|
}
|
|
|
|
public static func getFollowSuggestions() -> Request<[Account]> {
|
|
return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
|
|
}
|
|
|
|
public static func followRemote(acct: String) -> Request<Account> {
|
|
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
|
}
|
|
|
|
public static func getFollowedHashtags() -> Request<[Hashtag]> {
|
|
return Request(method: .get, path: "/api/v1/followed_tags")
|
|
}
|
|
|
|
// MARK: - Lists
|
|
public static func getLists() -> Request<[List]> {
|
|
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
|
}
|
|
|
|
public static func getList(id: String) -> Request<List> {
|
|
return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
|
|
}
|
|
|
|
public static func createList(title: String) -> Request<List> {
|
|
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
|
|
}
|
|
|
|
// MARK: - Media
|
|
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
|
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
|
|
"description" => description,
|
|
"focus" => focus
|
|
], attachment))
|
|
}
|
|
|
|
public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
|
|
return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([
|
|
"description" => description,
|
|
"focus" => focus
|
|
], nil))
|
|
}
|
|
|
|
// MARK: - Mutes
|
|
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
|
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
|
request.range = range
|
|
return request
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
|
"types" => allowedTypes.map { $0.rawValue }
|
|
)
|
|
request.range = range
|
|
return request
|
|
}
|
|
|
|
public static func getNotifications(excludedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
|
"exclude_types" => excludedTypes.map { $0.rawValue }
|
|
)
|
|
request.range = range
|
|
return request
|
|
}
|
|
|
|
public static func clearNotifications() -> Request<Empty> {
|
|
return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
|
|
}
|
|
|
|
// MARK: - Reports
|
|
public static func getReports() -> Request<[Report]> {
|
|
return Request<[Report]>(method: .get, path: "/api/v1/reports")
|
|
}
|
|
|
|
public static func report(
|
|
account: String,
|
|
statuses: [String],
|
|
comment: String,
|
|
forward: Bool,
|
|
category: String,
|
|
ruleIDs: [String]
|
|
) -> Request<Report> {
|
|
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
|
|
"account_id" => account,
|
|
"comment" => comment,
|
|
"forward" => forward,
|
|
"category" => category,
|
|
] + "status_ids" => statuses + "rule_ids" => ruleIDs))
|
|
}
|
|
|
|
// MARK: - Search
|
|
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil, following: Bool? = nil) -> Request<SearchResults> {
|
|
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
|
"q" => query,
|
|
"resolve" => resolve,
|
|
"limit" => limit,
|
|
"following" => following,
|
|
] + "types" => types?.map { $0.rawValue })
|
|
}
|
|
|
|
// MARK: - Statuses
|
|
public static func getStatus(id: String) -> Request<Status> {
|
|
return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
|
|
}
|
|
|
|
public static func createStatus(text: String,
|
|
contentType: StatusContentType = .plain,
|
|
inReplyTo: String? = nil,
|
|
mediaIDs: [String]? = nil,
|
|
sensitive: Bool? = nil,
|
|
spoilerText: String? = nil,
|
|
visibility: Visibility? = nil,
|
|
language: String? = nil, // language supported by mastodon and akkoma
|
|
pollOptions: [String]? = nil,
|
|
pollExpiresIn: Int? = nil,
|
|
pollMultiple: Bool? = nil,
|
|
localOnly: Bool? = nil /* hometown only, not glitch */) -> Request<Status> {
|
|
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
|
"status" => text,
|
|
"content_type" => contentType.mimeType,
|
|
"in_reply_to_id" => inReplyTo,
|
|
"sensitive" => sensitive,
|
|
"spoiler_text" => spoilerText,
|
|
"visibility" => visibility?.rawValue,
|
|
"language" => language,
|
|
"poll[expires_in]" => pollExpiresIn,
|
|
"poll[multiple]" => pollMultiple,
|
|
"local_only" => localOnly,
|
|
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
|
|
}
|
|
|
|
public static func editStatus(
|
|
id: String,
|
|
text: String,
|
|
contentType: StatusContentType = .plain,
|
|
spoilerText: String?,
|
|
sensitive: Bool,
|
|
language: String?,
|
|
mediaIDs: [String],
|
|
mediaAttributes: [EditStatusMediaAttributes],
|
|
poll: EditPollParameters?
|
|
) -> Request<Status> {
|
|
let params = EditStatusParameters(
|
|
id: id,
|
|
text: text,
|
|
contentType: contentType,
|
|
spoilerText: spoilerText,
|
|
sensitive: sensitive,
|
|
language: language,
|
|
mediaIDs: mediaIDs,
|
|
mediaAttributes: mediaAttributes,
|
|
poll: poll
|
|
)
|
|
return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params))
|
|
}
|
|
|
|
// MARK: - Timelines
|
|
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
|
return timeline.request(range: range)
|
|
}
|
|
|
|
|
|
// MARK: - Bookmarks
|
|
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
|
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
|
request.range = range
|
|
return request
|
|
}
|
|
|
|
// MARK: - Instance
|
|
public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
|
|
var parameters: [Parameter] = []
|
|
if let limit {
|
|
parameters.append("limit" => limit)
|
|
}
|
|
if let offset {
|
|
parameters.append("offset" => offset)
|
|
}
|
|
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
|
|
}
|
|
|
|
public static func getTrendingHashtags(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
|
|
var parameters: [Parameter] = []
|
|
if let limit {
|
|
parameters.append("limit" => limit)
|
|
}
|
|
if let offset {
|
|
parameters.append("offset" => offset)
|
|
}
|
|
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
|
}
|
|
|
|
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
|
var parameters: [Parameter] = []
|
|
if let limit {
|
|
parameters.append("limit" => limit)
|
|
}
|
|
if let offset {
|
|
parameters.append("offset" => offset)
|
|
}
|
|
return Request(method: .get, path: "/api/v1/trends/statuses", queryParameters: parameters)
|
|
}
|
|
|
|
public static func getTrendingLinks(limit: Int? = nil, offset: Int? = nil) -> Request<[Card]> {
|
|
var parameters: [Parameter] = []
|
|
if let limit {
|
|
parameters.append("limit" => limit)
|
|
}
|
|
if let offset {
|
|
parameters.append("offset" => offset)
|
|
}
|
|
return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
|
|
}
|
|
|
|
public static func getFeaturedProfiles(local: Bool, order: DirectoryOrder, offset: Int? = nil, limit: Int? = nil) -> Request<[Account]> {
|
|
var parameters = [
|
|
"order" => order.rawValue,
|
|
"local" => local,
|
|
]
|
|
if let offset = offset {
|
|
parameters.append("offset" => offset)
|
|
}
|
|
if let limit = limit {
|
|
parameters.append("limit" => limit)
|
|
}
|
|
return Request<[Account]>(method: .get, path: "/api/v1/directory", queryParameters: parameters)
|
|
}
|
|
|
|
public static func getSuggestions(limit: Int?) -> Request<[Suggestion]> {
|
|
return Request(method: .get, path: "/api/v2/suggestions", queryParameters: [
|
|
"limit" => limit,
|
|
])
|
|
}
|
|
|
|
// MARK: - Hashtags
|
|
/// Requires Mastodon 4.0.0+
|
|
public static func getHashtag(name: String) -> Request<Hashtag> {
|
|
return Request(method: .get, path: "/api/v1/tags/\(name)")
|
|
}
|
|
|
|
}
|
|
|
|
extension Client {
|
|
public struct Error: LocalizedError, Sendable {
|
|
public let requestMethod: Method
|
|
public let requestEndpoint: Endpoint
|
|
public let type: ErrorType
|
|
|
|
#if DEBUG
|
|
public static let debug = Error(request: Client.getStatuses(timeline: .home), type: .invalidResponse)
|
|
#endif
|
|
|
|
init<ResultType: Decodable>(request: Request<ResultType>, type: ErrorType) {
|
|
self.requestMethod = request.method
|
|
self.requestEndpoint = request.endpoint
|
|
self.type = type
|
|
}
|
|
|
|
public var errorDescription: String? {
|
|
switch type {
|
|
case .networkError(let error):
|
|
return "Network Error: \(error.localizedDescription)"
|
|
// todo: support more status codes
|
|
case .unexpectedStatus(413):
|
|
return "HTTP 413: Payload Too Large"
|
|
case .unexpectedStatus(429):
|
|
return "HTTP 429: Rate Limit Exceeded"
|
|
case .unexpectedStatus(let code):
|
|
return "HTTP Code \(code)"
|
|
case .invalidRequest:
|
|
return "Invalid Request"
|
|
case .invalidResponse:
|
|
return "Invalid Response"
|
|
case .invalidModel(_):
|
|
return "Invalid Model"
|
|
case .mastodonError(let code, let error):
|
|
return "Server Error (\(code)): \(error)"
|
|
}
|
|
}
|
|
}
|
|
public enum ErrorType: LocalizedError, Sendable {
|
|
case networkError(Swift.Error)
|
|
case unexpectedStatus(Int)
|
|
case invalidRequest
|
|
case invalidResponse
|
|
case invalidModel(Swift.Error)
|
|
case mastodonError(Int, String)
|
|
}
|
|
}
|