197 lines
6.7 KiB
Swift
197 lines
6.7 KiB
Swift
//
|
|
// APIController.swift
|
|
// MastoSearch
|
|
//
|
|
// Created by Shadowfacts on 2/19/22.
|
|
//
|
|
|
|
import Cocoa
|
|
|
|
struct APIController {
|
|
|
|
static let shared = APIController()
|
|
|
|
let scopes = "read"
|
|
let redirectScheme = "mastosearch"
|
|
let redirectURI = "mastosearch://oauth"
|
|
|
|
private 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
|
|
}()
|
|
|
|
private init() {}
|
|
|
|
private func run<R: Decodable>(request: URLRequest, completion: @escaping (Result<R, Error>) -> Void) {
|
|
var request = request
|
|
if let accessToken = LocalData.account?.accessToken {
|
|
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
|
if let error = error {
|
|
completion(.failure(.error(error)))
|
|
return
|
|
}
|
|
let response = response as! HTTPURLResponse
|
|
guard response.statusCode == 200 else {
|
|
completion(.failure(.unexpectedStatusCode(response.statusCode)))
|
|
return
|
|
}
|
|
guard let data = data else {
|
|
completion(.failure(.noData))
|
|
return
|
|
}
|
|
do {
|
|
let statuses = try decoder.decode(R.self, from: data)
|
|
completion(.success(statuses))
|
|
} catch {
|
|
completion(.failure(.decoding(error)))
|
|
return
|
|
}
|
|
}
|
|
task.resume()
|
|
}
|
|
|
|
func register(completion: @escaping (Result<ClientRegistration, Error>) -> Void) {
|
|
guard let account = LocalData.account else {
|
|
return
|
|
}
|
|
|
|
var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)!
|
|
components.path = "/api/v1/apps"
|
|
var req = URLRequest(url: components.url!)
|
|
req.httpMethod = "POST"
|
|
req.httpBody = [
|
|
("client_name", "MastoSearch"),
|
|
("redirect_uris", redirectURI),
|
|
("scopes", scopes),
|
|
].map { "\($0.0)=\($0.1)" }.joined(separator: "&").data(using: .utf8)!
|
|
run(request: req, completion: completion)
|
|
}
|
|
|
|
func getAccessToken(authCode: String, completion: @escaping (Result<LoginSettings, Error>) -> Void) {
|
|
guard let account = LocalData.account else {
|
|
return
|
|
}
|
|
|
|
var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)!
|
|
components.path = "/oauth/token"
|
|
var req = URLRequest(url: components.url!)
|
|
req.httpMethod = "POST"
|
|
req.httpBody = [
|
|
("client_id", account.clientID!),
|
|
("client_secret", account.clientSecret!),
|
|
("grant_type", "authorization_code"),
|
|
("code", authCode),
|
|
("redirect_uri", redirectURI),
|
|
].map { "\($0.0)=\($0.1)" }.joined(separator: "&").data(using: .utf8)!
|
|
run(request: req, completion: completion)
|
|
}
|
|
|
|
func getStatuses(range: RequestRange, completion: @escaping (Result<[Status], Error>) -> Void) {
|
|
guard let account = LocalData.account else {
|
|
return
|
|
}
|
|
var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)!
|
|
components.path = "/api/v1/accounts/1/statuses"
|
|
components.queryItems = range.queryParameters + [
|
|
URLQueryItem(name: "exclude_replies", value: "false"),
|
|
]
|
|
run(request: URLRequest(url: components.url!), completion: completion)
|
|
}
|
|
|
|
}
|
|
|
|
extension APIController {
|
|
enum RequestRange {
|
|
case `default`
|
|
case after(String)
|
|
|
|
var queryParameters: [URLQueryItem] {
|
|
switch self {
|
|
case .default:
|
|
return []
|
|
case .after(let id):
|
|
return [URLQueryItem(name: "min_id", value: id)]
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ClientRegistration: Decodable {
|
|
let client_id: String
|
|
let client_secret: String
|
|
}
|
|
|
|
struct LoginSettings: Decodable {
|
|
let access_token: String
|
|
}
|
|
|
|
struct Status: Decodable {
|
|
let id: String
|
|
let url: String
|
|
let spoiler_text: String
|
|
let content: String
|
|
let created_at: Date
|
|
let hasReblog: Bool
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.id = try container.decode(String.self, forKey: .id)
|
|
self.url = try container.decode(String.self, forKey: .url)
|
|
self.spoiler_text = try container.decode(String.self, forKey: .spoiler_text)
|
|
self.content = try container.decode(String.self, forKey: .content)
|
|
self.created_at = try container.decode(Date.self, forKey: .created_at)
|
|
if container.contains(.reblog) {
|
|
self.hasReblog = !(try container.decodeNil(forKey: .reblog))
|
|
} else {
|
|
self.hasReblog = false
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, url, spoiler_text, content, created_at, reblog
|
|
}
|
|
}
|
|
}
|
|
|
|
extension APIController {
|
|
enum Error: Swift.Error {
|
|
case unexpectedStatusCode(Int)
|
|
case error(Swift.Error)
|
|
case noData
|
|
case decoding(Swift.Error)
|
|
|
|
var localizedDescription: String {
|
|
switch self {
|
|
case .unexpectedStatusCode(let code):
|
|
return "Unexpected status code \(code)"
|
|
case .error(let inner):
|
|
return inner.localizedDescription
|
|
case .noData:
|
|
return "No data"
|
|
case .decoding(let error):
|
|
return "Decoding: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
}
|