Tusker/Packages/Pachyderm/Sources/Pachyderm/Client.swift

600 lines
24 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
}
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in
run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
}
}
}
}
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
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name)
}
if let mimeType = request.body.mimeType {
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
}
if let accessToken = accessToken {
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
// We consider authenticated requests to be user-initiated.
urlRequest.attribution = .user
}
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")
}
public static func getPreferences() -> Request<Preferences> {
return Request(method: .get, path: "/api/v1/preferences")
}
// 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: String? = 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 */
idempotencyKey: String) -> Request<Status> {
var req = 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,
"language" => language,
"poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple,
"local_only" => localOnly,
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
req.headers["Idempotency-Key"] = idempotencyKey
return req
}
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)
}
}