From a78430984a0e68cb4937f6300104490f8a18fed7 Mon Sep 17 00:00:00 2001 From: rinsuki <428rinsuki+git@gmail.com> Date: Sat, 20 Jan 2024 19:26:48 +0900 Subject: [PATCH] refactor: typed anonymous requests, no more Alamofire for Mastodon API calling --- .../Mastodon/API/MastodonPostContent.swift | 2 +- .../MastodonEndpointProtocol.swift | 66 ++++++---- Sources/Core/Mastodon/Model/MastodonApp.swift | 49 +++++-- .../Mastodon/Model/MastodonInstance.swift | 124 +++++++++++++----- .../Mastodon/Model/MastodonUserToken.swift | 43 +----- 5 files changed, 179 insertions(+), 105 deletions(-) diff --git a/Sources/Core/Mastodon/API/MastodonPostContent.swift b/Sources/Core/Mastodon/API/MastodonPostContent.swift index bb3f9c943..7c3e6a4a0 100644 --- a/Sources/Core/Mastodon/API/MastodonPostContent.swift +++ b/Sources/Core/Mastodon/API/MastodonPostContent.swift @@ -23,7 +23,7 @@ import Foundation -public struct MastodonPostContent: Codable, EmojifyProtocol, MastodonPostContentProtocol { +public struct MastodonPostContent: Codable, EmojifyProtocol, MastodonPostContentProtocol, MastodonEndpointResponse { public let status: String public let sensitive: Bool public let spoilerText: String diff --git a/Sources/Core/Mastodon/EndpointProtocol/MastodonEndpointProtocol.swift b/Sources/Core/Mastodon/EndpointProtocol/MastodonEndpointProtocol.swift index 2ed644988..df155f090 100644 --- a/Sources/Core/Mastodon/EndpointProtocol/MastodonEndpointProtocol.swift +++ b/Sources/Core/Mastodon/EndpointProtocol/MastodonEndpointProtocol.swift @@ -24,8 +24,8 @@ import Foundation import Hydra -public protocol MastodonEndpointProtocol { - associatedtype Response: MastodonEndpointResponse, Sendable +public protocol APIEndpointProtocol { + associatedtype Response: APIEndpointResponse, Sendable /// e.g. "/api/v1/account". you need to percent-encoding on some characters. var endpoint: String { get } @@ -35,14 +35,48 @@ public protocol MastodonEndpointProtocol { func body() throws -> (Data, contentType: String)? } -extension MastodonEndpointProtocol { +extension APIEndpointProtocol { public var query: [URLQueryItem] { return [] } public func body() throws -> (Data, contentType: String)? { return nil } - - public func request(with token: MastodonUserToken) async throws -> Self.Response { - return try await token.request(self) +} + +public protocol JSONAPIEndpointProtocol: APIEndpointProtocol {} + +extension JSONAPIEndpointProtocol where Self: Encodable { + public func body() throws -> (Data, contentType: String)? { + return (try JSONEncoder().encode(self), "application/json") + } +} + +public protocol APIEndpointResponse { + static func decode(data: Data, httpHeaders: [String: String]) throws -> Self +} + +public protocol JSONAPIEndpointResponse: APIEndpointResponse {} + +extension JSONAPIEndpointResponse where Self: Decodable { + public static func decode(data: Data, httpHeaders: [String: String]) throws -> Self { + let decoder = JSONDecoder.forMastodonAPI + return try decoder.decode(Self.self, from: data) + } +} + +extension Array: JSONAPIEndpointResponse, APIEndpointResponse where Self: Decodable, Element: JSONAPIEndpointResponse { + public static func decode(data: Data, httpHeaders: [String: String]) throws -> Self { + let decoder = JSONDecoder.forMastodonAPI + return try decoder.decode(Self.self, from: data) + } +} + +public typealias MastodonEndpointResponse = JSONAPIEndpointResponse + +public protocol MastodonEndpointProtocol: JSONAPIEndpointProtocol {} + +extension MastodonEndpointProtocol { + public func request(with token: MastodonUserToken, session: URLSession = .shared) async throws -> Self.Response { + return try await token.request(self, session: session) } @available(*, deprecated, message: "Use native async/await version instead.") @@ -59,22 +93,10 @@ extension MastodonEndpointProtocol { } } -extension MastodonEndpointProtocol where Self: Encodable { - public func body() throws -> (Data, contentType: String)? { - return (try JSONEncoder().encode(self), "application/json") - } -} - -public protocol MastodonEndpointResponse { - static func decode(data: Data, httpHeaders: [String: String]) throws -> Self -} +public protocol MastodonAnonymousEndpointProtocol: MastodonEndpointProtocol {} -extension MastodonEndpointResponse where Self: Decodable { - public static func decode(data: Data, httpHeaders: [String: String]) throws -> Self { - let decoder = JSONDecoder.forMastodonAPI - return try decoder.decode(Self.self, from: data) +extension MastodonAnonymousEndpointProtocol { + public func request(to instance: MastodonInstance, session: URLSession = .shared) async throws -> Self.Response { + return try await instance.request(self, session: session) } } - -extension Array: MastodonEndpointResponse where Element: Decodable { -} diff --git a/Sources/Core/Mastodon/Model/MastodonApp.swift b/Sources/Core/Mastodon/Model/MastodonApp.swift index b617a015f..74f87e857 100644 --- a/Sources/Core/Mastodon/Model/MastodonApp.swift +++ b/Sources/Core/Mastodon/Model/MastodonApp.swift @@ -22,7 +22,6 @@ // limitations under the License. import Foundation -import Alamofire import GRDB public class MastodonApp: Hashable { @@ -43,7 +42,7 @@ public class MastodonApp: Hashable { public var instance: MastodonInstance var id: String - init(instance: MastodonInstance, info: MastodonInstance.CreateAppResponse, name: String, redirectUri: String) { + init(instance: MastodonInstance, info: MastodonEndpoint.CreateApp.Response, name: String, redirectUri: String) { self.instance = instance clientId = info.clientId clientSecret = info.clientSecret @@ -124,15 +123,43 @@ public class MastodonApp: Hashable { var access_token: String } - let res: Response = try await Alamofire.request("https://\(self.instance.hostName)/oauth/token", method: .post, parameters: [ - "grant_type": "authorization_code", - "redirect_uri": self.redirectUri, - "client_id": self.clientId, - "client_secret": self.clientSecret, - "code": code, - "state": self.id, - ]).responseDecodable() + let response = try await MastodonEndpoint.AuthorizeWithCodeRequest( + redirectUri: redirectUri, + clientId: clientId, clientSecret: clientSecret, + code: code, state: id + ).request(to: instance) - return MastodonUserToken(app: self, token: res.access_token) + return MastodonUserToken(app: self, token: response.accessToken) + } +} + +extension MastodonEndpoint { + public struct AuthorizeWithCodeRequest: MastodonAnonymousEndpointProtocol, Encodable { + public struct Response: MastodonEndpointResponse, Decodable { + let accessToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + } + } + + public var endpoint: String { "/oauth/token" } + public var method: String { "POST" } + + public let grantType = "authorization_code" + public var redirectUri: String + public var clientId: String + public var clientSecret: String + public var code: String + public var state: String + + enum CodingKeys: String, CodingKey { + case grantType = "grant_type" + case redirectUri = "redirect_uri" + case clientId = "client_id" + case clientSecret = "client_secret" + case code + case state + } } } diff --git a/Sources/Core/Mastodon/Model/MastodonInstance.swift b/Sources/Core/Mastodon/Model/MastodonInstance.swift index f7cd81481..0581e2f1e 100644 --- a/Sources/Core/Mastodon/Model/MastodonInstance.swift +++ b/Sources/Core/Mastodon/Model/MastodonInstance.swift @@ -27,14 +27,14 @@ var mastodonInstanceInfoCache: [String: MastodonInstance.Info] = [:] #if os(macOS) public let defaultAppName = "iMast (macOS)" -private let website = "https://cinderella-project.github.io/iMast/mac/" +private let website = URL(string: "https://cinderella-project.github.io/iMast/mac/")! #else public let defaultAppName = "iMast" -private let website = "https://cinderella-project.github.io/iMast/" +private let website = URL(string: "https://cinderella-project.github.io/iMast/")! #endif public class MastodonInstance { - public struct Info: Codable { + public struct Info: Codable, MastodonEndpointResponse { public let version: String public let urls: Urls @@ -52,16 +52,6 @@ public class MastodonInstance { } } - struct CreateAppResponse: Codable { - let clientId: String - let clientSecret: String - - enum CodingKeys: String, CodingKey { - case clientId = "client_id" - case clientSecret = "client_secret" - } - } - public var hostName: String public var url: URL { return URL(string: "https://\(self.hostName)")! @@ -71,33 +61,105 @@ public class MastodonInstance { self.hostName = hostName.replacing(/.+@/, with: "").lowercased() } + private func makeRequest(_ ep: E) throws -> URLRequest { + var urlBuilder = URLComponents() + urlBuilder.scheme = "https" + urlBuilder.host = hostName + urlBuilder.percentEncodedPath = ep.endpoint + urlBuilder.queryItems = ep.query + if urlBuilder.queryItems?.count == 0 { + urlBuilder.queryItems = nil + } + var request = URLRequest(url: urlBuilder.url!) + request.httpMethod = ep.method + if let (body, contentType) = try ep.body() { + request.httpBody = body + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + request.setValue(UserAgentString, forHTTPHeaderField: "User-Agent") + + return request + } + + private func parseResponse(_ ep: E, data: Data, response: URLResponse) throws -> E.Response { + if let response = response as? HTTPURLResponse, response.statusCode >= 400 { + if let error = try? JSONDecoder.forMastodonAPI.decode(MastodonErrorResponse.self, from: data) { + throw APIError.errorReturned(errorMessage: error.error, errorHttpCode: response.statusCode) + } else { + throw APIError.unknownResponse(errorHttpCode: response.statusCode, errorString: .init(data: data, encoding: .utf8)) + } + } + return try E.Response.decode( + data: data, + httpHeaders: (response as! HTTPURLResponse).allHeaderFields as! [String: String] + ) + } + + internal func request(_ ep: E, session: URLSession = .shared, requestModifier: (inout URLRequest) -> Void) async throws -> E.Response { + var request = try makeRequest(ep) + requestModifier(&request) + let (data, response) = try await session.data(for: request) + return try parseResponse(ep, data: data, response: response) + } + + internal func request(_ ep: E, session: URLSession = .shared) async throws -> E.Response { + return try await request(ep, session: session) { _ in } + } + public func getInfo() async throws -> Info { if let cache = mastodonInstanceInfoCache[self.hostName] { return cache } - var request = try URLRequest(url: URL(string: "https://\(hostName)/api/v1/instance")!, method: .get) - request.setValue(UserAgentString, forHTTPHeaderField: "User-Agent") - let data = try await MastodonAPI.handleHTTPError(URLSession.shared.data(for: request)) - let json = try JSONDecoder.forMastodonAPI.decode(Info.self, from: data) + let json = try await MastodonEndpoint.GetInstanceInfo().request(to: self) mastodonInstanceInfoCache[self.hostName] = json return json } - public func createApp(name: String = defaultAppName, redirect_uri: String = "imast://callback/") async throws -> MastodonApp { - let params = [ - "client_name": name, - "scopes": "read write follow", - "redirect_uris": redirect_uri, - "website": website, - ] - var request = try URLRequest(url: URL(string: "https://\(hostName)/api/v1/apps")!, method: .post) - request.setValue(UserAgentString, forHTTPHeaderField: "User-Agent") - request.setValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(params) - let data = try await MastodonAPI.handleHTTPError(URLSession.shared.data(for: request)) - let json = try JSONDecoder.forMastodonAPI.decode(CreateAppResponse.self, from: data) + public func createApp(name: String = defaultAppName, redirect_uri: URL = URL(string: "imast://callback/")!) async throws -> MastodonApp { + let json = try await MastodonEndpoint.CreateApp( + clientName: name, + scopes: "read write follow", + redirectUri: redirect_uri, + website: website + ).request(to: self) + + return MastodonApp(instance: self, info: json, name: name, redirectUri: redirect_uri.absoluteString) + } +} + +extension MastodonEndpoint { + struct GetInstanceInfo: MastodonAnonymousEndpointProtocol { + typealias Response = MastodonInstance.Info + + var endpoint: String { "/api/v1/instance" } + var method: String { "GET" } + } + + struct CreateApp: MastodonAnonymousEndpointProtocol, Encodable { + struct Response: Codable, MastodonEndpointResponse { + let clientId: String + let clientSecret: String + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case clientSecret = "client_secret" + } + } - return MastodonApp(instance: self, info: json, name: name, redirectUri: redirect_uri) + var endpoint: String { "/api/v1/apps" } + var method: String { "POST" } + + var clientName: String + var scopes: String + var redirectUri: URL + var website: URL + + enum CodingKeys: String, CodingKey { + case clientName = "client_name" + case scopes + case redirectUri = "redirect_uris" + case website + } } } diff --git a/Sources/Core/Mastodon/Model/MastodonUserToken.swift b/Sources/Core/Mastodon/Model/MastodonUserToken.swift index 299eb611f..af3cb9b0c 100644 --- a/Sources/Core/Mastodon/Model/MastodonUserToken.swift +++ b/Sources/Core/Mastodon/Model/MastodonUserToken.swift @@ -47,15 +47,6 @@ public class MastodonUserToken: Equatable, @unchecked Sendable { self.id = genRandomString() } - func getHeader() -> [String: String] { - print(UserAgentString) - return [ - "Authorization": "Bearer "+token, - "Accept-Language": "en-US,en", - "User-Agent": UserAgentString, - ] - } - public func getIntVersion() async throws -> MastodonVersionInt { return MastodonVersionInt(try await self.app.instance.getInfo().version) } @@ -216,38 +207,10 @@ public class MastodonUserToken: Equatable, @unchecked Sendable { return response } - internal func request(_ ep: E) async throws -> E.Response { - var urlBuilder = URLComponents() - urlBuilder.scheme = "https" - urlBuilder.host = app.instance.hostName - urlBuilder.percentEncodedPath = ep.endpoint - urlBuilder.queryItems = ep.query - if urlBuilder.queryItems?.count == 0 { - urlBuilder.queryItems = nil - } - let headers = getHeader() - var request = URLRequest(url: try urlBuilder.asURL()) - request.httpMethod = ep.method - if let (body, contentType) = try ep.body() { - request.httpBody = body - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - } - for (name, value) in headers { - request.setValue(value, forHTTPHeaderField: name) + internal func request(_ ep: E, session: URLSession = .shared) async throws -> E.Response { + return try await app.instance.request(ep, session: session) { request in + request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } - print(request.httpMethod!, request.url!) - let (data, response) = try await URLSession.shared.data(for: request) - if let response = response as? HTTPURLResponse, response.statusCode >= 400 { - if let error = try? JSONDecoder.forMastodonAPI.decode(MastodonErrorResponse.self, from: data) { - throw APIError.errorReturned(errorMessage: error.error, errorHttpCode: response.statusCode) - } else { - throw APIError.unknownResponse(errorHttpCode: response.statusCode, errorString: .init(data: data, encoding: .utf8)) - } - } - return try E.Response.decode( - data: data, - httpHeaders: (response as! HTTPURLResponse).allHeaderFields as! [String: String] - ) } public static func == (lhs: MastodonUserToken, rhs: MastodonUserToken) -> Bool {