Skip to content

Commit

Permalink
refactor: typed anonymous requests, no more Alamofire for Mastodon AP…
Browse files Browse the repository at this point in the history
…I calling
  • Loading branch information
rinsuki committed Jan 20, 2024
1 parent 37dda5d commit a784309
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 105 deletions.
2 changes: 1 addition & 1 deletion Sources/Core/Mastodon/API/MastodonPostContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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.")
Expand All @@ -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 {
}
49 changes: 38 additions & 11 deletions Sources/Core/Mastodon/Model/MastodonApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
// limitations under the License.

import Foundation
import Alamofire
import GRDB

public class MastodonApp: Hashable {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
}
124 changes: 93 additions & 31 deletions Sources/Core/Mastodon/Model/MastodonInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)")!
Expand All @@ -71,33 +61,105 @@ public class MastodonInstance {
self.hostName = hostName.replacing(/.+@/, with: "").lowercased()
}

private func makeRequest<E: MastodonEndpointProtocol>(_ 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<E: MastodonEndpointProtocol>(_ 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<E: MastodonEndpointProtocol>(_ 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<E: MastodonAnonymousEndpointProtocol>(_ 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
}
}
}
43 changes: 3 additions & 40 deletions Sources/Core/Mastodon/Model/MastodonUserToken.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -216,38 +207,10 @@ public class MastodonUserToken: Equatable, @unchecked Sendable {
return response
}

internal func request<E: MastodonEndpointProtocol>(_ 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<E: MastodonEndpointProtocol>(_ 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 {
Expand Down

0 comments on commit a784309

Please sign in to comment.