MastoSearch/MastoSearch/APIController.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)"
}
}
}
}