A WIP iOS app for Mastodon and Pleroma.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Client.swift 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. //
  2. // Client.swift
  3. // Pachyderm
  4. //
  5. // Created by Shadowfacts on 9/8/18.
  6. // Copyright © 2018 Shadowfacts. All rights reserved.
  7. //
  8. import Foundation
  9. /**
  10. The base Mastodon API client.
  11. */
  12. public class Client {
  13. public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
  14. let baseURL: URL
  15. let session: URLSession
  16. public var accessToken: String?
  17. public var appID: String?
  18. public var clientID: String?
  19. public var clientSecret: String?
  20. public var timeoutInterval: TimeInterval = 60
  21. lazy var decoder: JSONDecoder = {
  22. let decoder = JSONDecoder()
  23. let formatter = DateFormatter()
  24. formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
  25. formatter.timeZone = TimeZone(abbreviation: "UTC")
  26. formatter.locale = Locale(identifier: "en_US_POSIX")
  27. decoder.dateDecodingStrategy = .formatted(formatter)
  28. return decoder
  29. }()
  30. public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
  31. self.baseURL = baseURL
  32. self.accessToken = accessToken
  33. self.session = session
  34. }
  35. public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) {
  36. guard let request = createURLRequest(request: request) else {
  37. completion(.failure(Error.invalidRequest))
  38. return
  39. }
  40. let task = session.dataTask(with: request) { data, response, error in
  41. if let error = error {
  42. completion(.failure(error))
  43. return
  44. }
  45. guard let data = data,
  46. let response = response as? HTTPURLResponse else {
  47. completion(.failure(Error.invalidResponse))
  48. return
  49. }
  50. guard response.statusCode == 200 else {
  51. let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
  52. let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
  53. completion(.failure(error))
  54. return
  55. }
  56. guard let result = try? self.decoder.decode(Result.self, from: data) else {
  57. completion(.failure(Error.invalidModel))
  58. return
  59. }
  60. let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
  61. completion(.success(result, pagination))
  62. }
  63. task.resume()
  64. }
  65. func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
  66. guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
  67. components.path = request.path
  68. components.queryItems = request.queryParameters.queryItems
  69. guard let url = components.url else { return nil }
  70. var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
  71. urlRequest.httpMethod = request.method.name
  72. urlRequest.httpBody = request.body.data
  73. urlRequest.setValue(request.body.mimeType, forHTTPHeaderField: "Content-Type")
  74. if let accessToken = accessToken {
  75. urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  76. }
  77. return urlRequest
  78. }
  79. // MARK: - Authorization
  80. public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
  81. let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
  82. "client_name" => name,
  83. "redirect_uris" => redirectURI,
  84. "scopes" => scopes.scopeString,
  85. "website" => website?.absoluteString
  86. ]))
  87. run(request) { result in
  88. defer { completion(result) }
  89. guard case let .success(application, _) = result else { return }
  90. self.appID = application.id
  91. self.clientID = application.clientID
  92. self.clientSecret = application.clientSecret
  93. }
  94. }
  95. public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
  96. let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
  97. "client_id" => clientID,
  98. "client_secret" => clientSecret,
  99. "grant_type" => "authorization_code",
  100. "code" => authorizationCode,
  101. "redirect_uri" => redirectURI
  102. ]))
  103. run(request) { result in
  104. defer { completion(result) }
  105. guard case let .success(loginSettings, _) = result else { return }
  106. self.accessToken = loginSettings.accessToken
  107. }
  108. }
  109. // MARK: - Self
  110. public static func getSelfAccount() -> Request<Account> {
  111. return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
  112. }
  113. public static func getFavourites() -> Request<[Status]> {
  114. return Request<[Status]>(method: .get, path: "/api/v1/favourites")
  115. }
  116. public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
  117. return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
  118. }
  119. public static func getInstance() -> Request<Instance> {
  120. return Request<Instance>(method: .get, path: "/api/v1/instance")
  121. }
  122. public static func getCustomEmoji() -> Request<[Emoji]> {
  123. return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
  124. }
  125. // MARK: - Accounts
  126. public static func getAccount(id: String) -> Request<Account> {
  127. return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
  128. }
  129. public static func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil) -> Request<[Account]> {
  130. return Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
  131. "q" => query,
  132. "limit" => limit,
  133. "following" => following
  134. ])
  135. }
  136. // MARK: - Blocks
  137. public static func getBlocks() -> Request<[Account]> {
  138. return Request<[Account]>(method: .get, path: "/api/v1/blocks")
  139. }
  140. public static func getDomainBlocks() -> Request<[String]> {
  141. return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
  142. }
  143. public static func block(domain: String) -> Request<Empty> {
  144. return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
  145. "domain" => domain
  146. ]))
  147. }
  148. public static func unblock(domain: String) -> Request<Empty> {
  149. return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
  150. "domain" => domain
  151. ]))
  152. }
  153. // MARK: - Filters
  154. public static func getFilters() -> Request<[Filter]> {
  155. return Request<[Filter]>(method: .get, path: "/api/v1/filters")
  156. }
  157. public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
  158. return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
  159. "phrase" => phrase,
  160. "irreversible" => irreversible,
  161. "whole_word" => wholeWord,
  162. "expires_at" => expiresAt
  163. ] + "context" => context.contextStrings))
  164. }
  165. public static func getFilter(id: String) -> Request<Filter> {
  166. return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
  167. }
  168. // MARK: - Follows
  169. public static func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
  170. var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
  171. request.range = range
  172. return request
  173. }
  174. public static func getFollowSuggestions() -> Request<[Account]> {
  175. return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
  176. }
  177. public static func followRemote(acct: String) -> Request<Account> {
  178. return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
  179. }
  180. // MARK: - Lists
  181. public static func getLists() -> Request<[List]> {
  182. return Request<[List]>(method: .get, path: "/api/v1/lists")
  183. }
  184. public static func getList(id: String) -> Request<List> {
  185. return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
  186. }
  187. public static func createList(title: String) -> Request<List> {
  188. return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
  189. }
  190. // MARK: - Media
  191. public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
  192. return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
  193. "description" => description,
  194. "focus" => focus
  195. ], attachment))
  196. }
  197. // MARK: - Mutes
  198. public static func getMutes(range: RequestRange) -> Request<[Account]> {
  199. var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
  200. request.range = range
  201. return request
  202. }
  203. // MARK: - Notifications
  204. public static func getNotifications(excludeTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
  205. var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
  206. "exclude_types" => excludeTypes.map { $0.rawValue }
  207. )
  208. request.range = range
  209. return request
  210. }
  211. public static func clearNotifications() -> Request<Empty> {
  212. return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
  213. }
  214. // MARK: - Reports
  215. public static func getReports() -> Request<[Report]> {
  216. return Request<[Report]>(method: .get, path: "/api/v1/reports")
  217. }
  218. public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
  219. return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
  220. "account_id" => account.id,
  221. "comment" => comment
  222. ] + "status_ids" => statuses.map { $0.id }))
  223. }
  224. // MARK: - Search
  225. public static func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
  226. return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
  227. "q" => query,
  228. "resolve" => resolve,
  229. "limit" => limit
  230. ])
  231. }
  232. // MARK: - Statuses
  233. public static func getStatus(id: String) -> Request<Status> {
  234. return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
  235. }
  236. public static func createStatus(text: String,
  237. contentType: StatusContentType = .plain,
  238. inReplyTo: String? = nil,
  239. media: [Attachment]? = nil,
  240. sensitive: Bool? = nil,
  241. spoilerText: String? = nil,
  242. visibility: Status.Visibility? = nil,
  243. language: String? = nil) -> Request<Status> {
  244. return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
  245. "status" => text,
  246. "content_type" => contentType.mimeType,
  247. "in_reply_to_id" => inReplyTo,
  248. "sensitive" => sensitive,
  249. "spoiler_text" => spoilerText,
  250. "visibility" => visibility?.rawValue,
  251. "language" => language
  252. ] + "media_ids" => media?.map { $0.id }))
  253. }
  254. // MARK: - Timelines
  255. public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
  256. return timeline.request(range: range)
  257. }
  258. // MARK: Bookmarks
  259. public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
  260. var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
  261. request.range = range
  262. return request
  263. }
  264. }
  265. extension Client {
  266. public enum Error: Swift.Error {
  267. case unknownError
  268. case invalidRequest
  269. case invalidResponse
  270. case invalidModel
  271. case mastodonError(String)
  272. }
  273. }