feat: refactor query type; add several Account APIs and tests

This commit is contained in:
jk234ert 2021-02-10 14:56:16 +08:00
parent 71a485fc4a
commit 9395f689ce
13 changed files with 415 additions and 53 deletions

View File

@ -186,6 +186,7 @@ extension MastodonRegisterViewController {
viewModel.isRegistering.value = true viewModel.isRegistering.value = true
let query = Mastodon.API.Account.RegisterQuery( let query = Mastodon.API.Account.RegisterQuery(
reason: "",
username: username, username: username,
email: email, email: email,
password: password, password: password,

View File

@ -54,13 +54,8 @@
}, },
"testTargets" : [ "testTargets" : [
{ {
"skippedTests" : [
"MastodonSDKTests\/testCreateAnAnpplication()",
"MastodonSDKTests\/testHomeTimeline()",
"MastodonSDKTests\/testVerifyAppCredentials()"
],
"target" : { "target" : {
"containerPath" : "container:MastodonSDK", "containerPath" : "container:",
"identifier" : "MastodonSDKTests", "identifier" : "MastodonSDKTests",
"name" : "MastodonSDKTests" "name" : "MastodonSDKTests"
} }

View File

@ -13,10 +13,24 @@ extension Mastodon.API.Account {
static func verifyCredentialsEndpointURL(domain: String) -> URL { static func verifyCredentialsEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials")
} }
static func registerEndpointURL(domain: String) -> URL { static func accountsEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts")
} }
static func updateCredentialsEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials")
}
/// Test to make sure that the user token works.
///
/// - Version: 3.0.0
/// # Last Update
/// 2021/2/9
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - Returns: `AnyPublisher` contains `Account` nested in the response
public static func verifyCredentials( public static func verifyCredentials(
session: URLSession, session: URLSession,
domain: String, domain: String,
@ -34,7 +48,18 @@ extension Mastodon.API.Account {
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
/// Creates a user and account records.
///
/// - Version: 3.0.0
/// # Last Update
/// 2021/2/9
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - Returns: `AnyPublisher` contains `Token` nested in the response
public static func register( public static func register(
session: URLSession, session: URLSession,
domain: String, domain: String,
@ -42,7 +67,7 @@ extension Mastodon.API.Account {
authorization: Mastodon.API.OAuth.Authorization authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> { ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
let request = Mastodon.API.post( let request = Mastodon.API.post(
url: registerEndpointURL(domain: domain), url: accountsEndpointURL(domain: domain),
query: query, query: query,
authorization: authorization authorization: authorization
) )
@ -53,6 +78,67 @@ extension Mastodon.API.Account {
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
/// Update the user's display and preferences.
///
/// - Version: 3.0.0
/// # Last Update
/// 2021/2/9
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: `CredentialQuery` with update information
/// - Returns: `AnyPublisher` contains updated `Account` nested in the response
public static func updateCredentials(
session: URLSession,
domain: String,
query: CredentialQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
let request = Mastodon.API.patch(
url: updateCredentialsEndpointURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
/// View information about a profile.
///
/// - Version: 3.0.0
/// # Last Update
/// 2021/2/9
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - Returns: `AnyPublisher` contains `Account` nested in the response
public static func accountInfo(
session: URLSession,
domain: String,
query: AccountInfoQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
let request = Mastodon.API.get(
url: accountsEndpointURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
} }
@ -74,10 +160,72 @@ extension Mastodon.API.Account {
self.agreement = agreement self.agreement = agreement
self.locale = locale self.locale = locale
} }
}
var body: Data? {
return try? Mastodon.API.encoder.encode(self) public struct CredentialQuery: Codable, PatchQuery {
public var discoverable: Bool?
public var bot: Bool?
public var displayName: String?
public var note: String?
public var avatar: String?
public var header: String?
public var locked: Bool?
public var sourcePrivacy: String?
public var sourceSensitive: Bool?
public var sourceLanguage: String?
public var fieldsAttributes: [Mastodon.Entity.Field]?
enum CodingKeys: String, CodingKey {
case discoverable
case bot
case displayName
case note
case avatar
case header
case locked
case sourcePrivacy = "source[privacy]"
case sourceSensitive = "source[sensitive]"
case sourceLanguage = "source[language]"
case fieldsAttributes = "fields_attributes"
}
public init(
discoverable: Bool? = nil,
bot: Bool? = nil,
displayName: String? = nil,
note: String? = nil,
avatar: Mastodon.Entity.MediaAttachment? = nil,
header: Mastodon.Entity.MediaAttachment? = nil,
locked: Bool? = nil,
sourcePrivacy: String? = nil,
sourceSensitive: Bool? = nil,
sourceLanguage: String? = nil,
fieldsAttributes: [Mastodon.Entity.Field]? = nil
) {
self.discoverable = discoverable
self.bot = bot
self.displayName = displayName
self.note = note
self.avatar = avatar?.base64EncondedString
self.header = header?.base64EncondedString
self.locked = locked
self.sourcePrivacy = sourcePrivacy
self.sourceSensitive = sourceSensitive
self.sourceLanguage = sourceLanguage
self.fieldsAttributes = fieldsAttributes
}
}
public struct AccountInfoQuery: Codable, GetQuery {
public let id: String
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
items.append(URLQueryItem(name: "id", value: id))
return items
} }
} }
} }

View File

@ -112,10 +112,6 @@ extension Mastodon.API.App {
self.scopes = scopes self.scopes = scopes
self.website = website self.website = website
} }
var body: Data? {
return try? Mastodon.API.encoder.encode(self)
}
} }
} }

View File

@ -30,6 +30,9 @@ extension Mastodon.API.OAuth {
static func accessTokenEndpointURL(domain: String) -> URL { static func accessTokenEndpointURL(domain: String) -> URL {
return Mastodon.API.oauthEndpointURL(domain: domain).appendingPathComponent("token") return Mastodon.API.oauthEndpointURL(domain: domain).appendingPathComponent("token")
} }
static func revokeTokenEndpointURL(domain: String) -> URL {
return Mastodon.API.oauthEndpointURL(domain: domain).appendingPathComponent("revoke")
}
/// Construct user authorize endpoint URL /// Construct user authorize endpoint URL
/// ///
@ -88,12 +91,43 @@ extension Mastodon.API.OAuth {
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
/// Revoke User Access Token
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/2/9
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/apps/oauth/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: `RevokeTokenQuery`
/// - Returns: `AnyPublisher` contains `Token` nested in the response
public static func revokeToken(
session: URLSession,
domain: String,
query: RevokeTokenQuery
) -> AnyPublisher<Void, Error> {
let request = Mastodon.API.post(
url: revokeTokenEndpointURL(domain: domain),
query: query,
authorization: nil
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
// `RevokeToken` returns an empty response when success, so just check whether the data type is String to avoid
_ = try Mastodon.API.decode(type: String.self, from: data, response: response)
}
.eraseToAnyPublisher()
}
} }
extension Mastodon.API.OAuth { extension Mastodon.API.OAuth {
public struct AuthorizeQuery: GetQuery { public struct AuthorizeQuery: Codable, GetQuery {
public let forceLogin: String? public let forceLogin: String?
public let responseType: String public let responseType: String
@ -162,11 +196,18 @@ extension Mastodon.API.OAuth {
case grantType = "grant_type" case grantType = "grant_type"
} }
}
var body: Data? {
return try? Mastodon.API.encoder.encode(self) public struct RevokeTokenQuery: Codable, PostQuery {
public let clientID: String
public let clientSecret: String
public let token: String
enum CodingKeys: String, CodingKey {
case clientID = "client_id"
case clientSecret = "client_secret"
case token
} }
} }
} }

View File

@ -96,25 +96,7 @@ extension Mastodon.API {
query: GetQuery?, query: GetQuery?,
authorization: OAuth.Authorization? authorization: OAuth.Authorization?
) -> URLRequest { ) -> URLRequest {
var components = URLComponents(string: url.absoluteString)! return buildRequest(url: url, query: query, authorization: authorization)
if let query = query {
components.queryItems = query.queryItems
}
let requestURL = components.url!
var request = URLRequest(
url: requestURL,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: Mastodon.API.timeoutInterval
)
if let authorization = authorization {
request.setValue(
"Bearer \(authorization.accessToken)",
forHTTPHeaderField: Mastodon.API.OAuth.authorizationField
)
}
request.httpMethod = "GET"
return request
} }
static func post( static func post(
@ -122,7 +104,26 @@ extension Mastodon.API {
query: PostQuery?, query: PostQuery?,
authorization: OAuth.Authorization? authorization: OAuth.Authorization?
) -> URLRequest { ) -> URLRequest {
let components = URLComponents(string: url.absoluteString)! return buildRequest(url: url, query: query, authorization: authorization)
}
static func patch(
url: URL,
query: PatchQuery?,
authorization: OAuth.Authorization?
) -> URLRequest {
return buildRequest(url: url, query: query, authorization: authorization)
}
private static func buildRequest(
url: URL,
query: RequestQuery?,
authorization: OAuth.Authorization?
) -> URLRequest {
var components = URLComponents(string: url.absoluteString)!
if let requestQuery = query as? GetQuery {
components.queryItems = requestQuery.queryItems
}
let requestURL = components.url! let requestURL = components.url!
var request = URLRequest( var request = URLRequest(
url: requestURL, url: requestURL,
@ -130,17 +131,17 @@ extension Mastodon.API {
timeoutInterval: Mastodon.API.timeoutInterval timeoutInterval: Mastodon.API.timeoutInterval
) )
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
if let query = query { request.httpBody = query?.body
request.httpBody = query.body
}
if let authorization = authorization { if let authorization = authorization {
request.setValue( request.setValue(
"Bearer \(authorization.accessToken)", "Bearer \(authorization.accessToken)",
forHTTPHeaderField: Mastodon.API.OAuth.authorizationField forHTTPHeaderField: Mastodon.API.OAuth.authorizationField
) )
} }
request.httpMethod = "POST"
request.httpMethod = query?.method.rawValue ?? RequestMethod.GET.rawValue
return request return request
} }
static func decode<T>(type: T.Type, from data: Data, response: URLResponse) throws -> T where T : Decodable { static func decode<T>(type: T.Type, from data: Data, response: URLResponse) throws -> T where T : Decodable {

View File

@ -0,0 +1,55 @@
//
// Mastodon+Entity+MediaAttachment.swift
//
//
// Created by jk234ert on 2/9/21.
//
import Foundation
extension Mastodon.Entity {
public enum MediaAttachment {
/// JPEG (Joint Photographic Experts Group) image
case jpeg(Data?)
/// GIF (Graphics Interchange Format) image
case gif(Data?)
/// PNG (Portable Network Graphics) image
case png(Data?)
/// Other media file
case other(Data?, fileExtension: String, mimeType: String)
}
}
extension Mastodon.Entity.MediaAttachment {
var data: Data? {
switch self {
case .jpeg(let data): return data
case .gif(let data): return data
case .png(let data): return data
case .other(let data, _, _): return data
}
}
var fileName: String {
switch self {
case .jpeg: return "file.jpg"
case .gif: return "file.gif"
case .png: return "file.png"
case .other(_, let fileExtension, _): return "file.\(fileExtension)"
}
}
var mimeType: String {
switch self {
case .jpeg: return "image/jpg"
case .gif: return "image/gif"
case .png: return "image/png"
case .other(_, _, let mimeType): return mimeType
}
}
var base64EncondedString: String? {
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() }
}
}

View File

@ -7,10 +7,38 @@
import Foundation import Foundation
protocol GetQuery { enum RequestMethod: String {
case GET, POST, PATCH, PUT, DELETE
}
protocol RequestQuery {
var body: Data? { get }
var method: RequestMethod { get }
}
extension RequestQuery where method: Encodable {
var body: Data? {
return try? Mastodon.API.encoder.encode(self)
}
}
protocol GetQuery: RequestQuery {
var queryItems: [URLQueryItem]? { get } var queryItems: [URLQueryItem]? { get }
} }
protocol PostQuery { extension GetQuery {
var body: Data? { get } var method: RequestMethod { return .GET }
var body: Data? { return nil }
}
protocol PostQuery: RequestQuery { }
extension PostQuery {
var method: RequestMethod { return .POST }
}
protocol PatchQuery: RequestQuery { }
extension PatchQuery {
var method: RequestMethod { return .PATCH }
} }

View File

@ -0,0 +1,72 @@
//
// MastodonSDK+API+AccountTests.swift
//
//
// Created by jk234ert on 2/9/21.
//
import os.log
import XCTest
import Combine
@testable import MastodonSDK
extension MastodonSDKTests {
func testVerifyCredentials() throws {
let theExpectation = expectation(description: "Verify Account Credentials")
let authorization = Mastodon.API.OAuth.Authorization(accessToken: testToken)
Mastodon.API.Account.verifyCredentials(session: session, domain: domain, authorization: authorization)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
XCTFail(error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
XCTAssertEqual(response.value.acct, "ugling88")
theExpectation.fulfill()
}
.store(in: &disposeBag)
wait(for: [theExpectation], timeout: 5.0)
}
func testUpdateCredentials() throws {
let theExpectation1 = expectation(description: "Verify Account Credentials")
let theExpectation2 = expectation(description: "Update Account Credentials")
let authorization = Mastodon.API.OAuth.Authorization(accessToken: testToken)
let dateString = "\(Date().timeIntervalSince1970)"
Mastodon.API.Account.verifyCredentials(session: session, domain: domain, authorization: authorization)
.flatMap({ (result) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
// TODO: replace with test account acct
XCTAssertEqual(result.value.acct, "")
theExpectation1.fulfill()
var query = Mastodon.API.Account.CredentialQuery()
query.note = dateString
return Mastodon.API.Account.updateCredentials(session: self.session, domain: self.domain, query: query, authorization: authorization)
})
.sink { completion in
switch completion {
case .failure(let error):
XCTFail(error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
// The server will generate the corresponding HTML.
// Here the updated `note` would be wrapped by a `p` tag by server
XCTAssertEqual(response.value.note, "<p>\(dateString)</p>")
theExpectation2.fulfill()
}
.store(in: &disposeBag)
wait(for: [theExpectation1, theExpectation2], timeout: 10.0)
}
}

View File

@ -50,7 +50,7 @@ extension MastodonSDKTests {
extension MastodonSDKTests { extension MastodonSDKTests {
func testVerifyAppCredentials() throws { func testVerifyAppCredentials() throws {
try _testVerifyAppCredentials(domain: domain, accessToken: "") try _testVerifyAppCredentials(domain: domain, accessToken: testToken)
} }
func _testVerifyAppCredentials(domain: String, accessToken: String) throws { func _testVerifyAppCredentials(domain: String, accessToken: String) throws {

View File

@ -26,4 +26,27 @@ extension MastodonSDKTests {
) )
} }
func testRevokeToken() throws {
_testRevokeTokenFail()
}
func _testRevokeTokenFail() {
let theExpectation = expectation(description: "Revoke Instance Infomation")
let query = Mastodon.API.OAuth.RevokeTokenQuery(clientID: "StubClientID", clientSecret: "", token: "")
Mastodon.API.OAuth.revokeToken(session: session, domain: domain, query: query)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure:
theExpectation.fulfill()
case .finished:
XCTFail("Success in a failed test?")
}
} receiveValue: { response in
}
.store(in: &disposeBag)
wait(for: [theExpectation], timeout: 10.0)
}
} }

View File

@ -43,8 +43,7 @@ extension MastodonSDKTests {
extension MastodonSDKTests { extension MastodonSDKTests {
func testHomeTimeline() { func testHomeTimeline() {
let domain = "" let accessToken = testToken
let accessToken = ""
guard !domain.isEmpty, !accessToken.isEmpty else { return } guard !domain.isEmpty, !accessToken.isEmpty else { return }
let query = Mastodon.API.Timeline.HomeTimelineQuery() let query = Mastodon.API.Timeline.HomeTimelineQuery()

View File

@ -8,6 +8,9 @@ final class MastodonSDKTests: XCTestCase {
let session = URLSession(configuration: .ephemeral) let session = URLSession(configuration: .ephemeral)
var domain: String { MastodonSDKTests.environmentVariable(key: "domain") } var domain: String { MastodonSDKTests.environmentVariable(key: "domain") }
// TODO: replace with test account token
var testToken = ""
static func environmentVariable(key: String) -> String { static func environmentVariable(key: String) -> String {
return ProcessInfo.processInfo.environment[key]! return ProcessInfo.processInfo.environment[key]!