From 9395f689ce03e5d4e6c9ee51a5763b3aa1d79c92 Mon Sep 17 00:00:00 2001 From: jk234ert Date: Wed, 10 Feb 2021 14:56:16 +0800 Subject: [PATCH] feat: refactor query type; add several Account APIs and tests --- .../MastodonRegisterViewController.swift | 1 + MastodonSDK.xctestplan | 7 +- .../API/Mastodon+API+Account.swift | 164 +++++++++++++++++- .../MastodonSDK/API/Mastodon+API+App.swift | 4 - .../MastodonSDK/API/Mastodon+API+OAuth.swift | 51 +++++- .../MastodonSDK/API/Mastodon+API.swift | 49 +++--- .../Mastodon+Entity+MediaAttachment.swift | 55 ++++++ .../Sources/MastodonSDK/Protocol/Query.swift | 34 +++- .../API/MastodonSDK+API+AccountTests.swift | 72 ++++++++ .../API/MastodonSDK+API+AppTests.swift | 2 +- .../API/MastodonSDK+API+OAuthTests.swift | 23 +++ .../API/MastodonSDK+API+TimelineTests.swift | 3 +- .../MastodonSDKTests/MastodonSDKTests.swift | 3 + 13 files changed, 415 insertions(+), 53 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift create mode 100644 MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift diff --git a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift index b25686815..0a8e69f5b 100644 --- a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift @@ -186,6 +186,7 @@ extension MastodonRegisterViewController { viewModel.isRegistering.value = true let query = Mastodon.API.Account.RegisterQuery( + reason: "", username: username, email: email, password: password, diff --git a/MastodonSDK.xctestplan b/MastodonSDK.xctestplan index 6d1b34603..35fa310e5 100644 --- a/MastodonSDK.xctestplan +++ b/MastodonSDK.xctestplan @@ -54,13 +54,8 @@ }, "testTargets" : [ { - "skippedTests" : [ - "MastodonSDKTests\/testCreateAnAnpplication()", - "MastodonSDKTests\/testHomeTimeline()", - "MastodonSDKTests\/testVerifyAppCredentials()" - ], "target" : { - "containerPath" : "container:MastodonSDK", + "containerPath" : "container:", "identifier" : "MastodonSDKTests", "name" : "MastodonSDKTests" } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 246cec89e..f7bec5954 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -13,10 +13,24 @@ extension Mastodon.API.Account { static func verifyCredentialsEndpointURL(domain: String) -> URL { 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") } - + 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( session: URLSession, domain: String, @@ -34,7 +48,18 @@ extension Mastodon.API.Account { } .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( session: URLSession, domain: String, @@ -42,7 +67,7 @@ extension Mastodon.API.Account { authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.post( - url: registerEndpointURL(domain: domain), + url: accountsEndpointURL(domain: domain), query: query, authorization: authorization ) @@ -53,6 +78,67 @@ extension Mastodon.API.Account { } .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, 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, 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.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 } } - } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift index 54105790f..3993afa68 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift @@ -112,10 +112,6 @@ extension Mastodon.API.App { self.scopes = scopes self.website = website } - - var body: Data? { - return try? Mastodon.API.encoder.encode(self) - } } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift index f6fa76bc2..1ab74625b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift @@ -30,6 +30,9 @@ extension Mastodon.API.OAuth { static func accessTokenEndpointURL(domain: String) -> URL { 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 /// @@ -88,12 +91,43 @@ extension Mastodon.API.OAuth { } .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 { + 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 { - public struct AuthorizeQuery: GetQuery { + public struct AuthorizeQuery: Codable, GetQuery { public let forceLogin: String? public let responseType: String @@ -162,11 +196,18 @@ extension Mastodon.API.OAuth { 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 } - } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 900ebf965..aaa38a4db 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -96,25 +96,7 @@ extension Mastodon.API { query: GetQuery?, authorization: OAuth.Authorization? ) -> URLRequest { - var components = URLComponents(string: url.absoluteString)! - 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 + return buildRequest(url: url, query: query, authorization: authorization) } static func post( @@ -122,7 +104,26 @@ extension Mastodon.API { query: PostQuery?, authorization: OAuth.Authorization? ) -> 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! var request = URLRequest( url: requestURL, @@ -130,17 +131,17 @@ extension Mastodon.API { timeoutInterval: Mastodon.API.timeoutInterval ) 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 { request.setValue( "Bearer \(authorization.accessToken)", forHTTPHeaderField: Mastodon.API.OAuth.authorizationField ) } - request.httpMethod = "POST" + + request.httpMethod = query?.method.rawValue ?? RequestMethod.GET.rawValue return request + } static func decode(type: T.Type, from data: Data, response: URLResponse) throws -> T where T : Decodable { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift new file mode 100644 index 000000000..39ffe23d7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift @@ -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() } + } +} + diff --git a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift b/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift index e95b9141d..e683baa9f 100644 --- a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift @@ -7,10 +7,38 @@ 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 } } -protocol PostQuery { - var body: Data? { get } +extension GetQuery { + 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 } } diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift new file mode 100644 index 000000000..09bb64b06 --- /dev/null +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift @@ -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, 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, "

\(dateString)

") + theExpectation2.fulfill() + } + .store(in: &disposeBag) + + + wait(for: [theExpectation1, theExpectation2], timeout: 10.0) + } +} diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AppTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AppTests.swift index f74fe61dc..476318e6b 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AppTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AppTests.swift @@ -50,7 +50,7 @@ extension MastodonSDKTests { extension MastodonSDKTests { func testVerifyAppCredentials() throws { - try _testVerifyAppCredentials(domain: domain, accessToken: "") + try _testVerifyAppCredentials(domain: domain, accessToken: testToken) } func _testVerifyAppCredentials(domain: String, accessToken: String) throws { diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift index 8c5f42603..547ee0991 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift @@ -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) + } + } diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+TimelineTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+TimelineTests.swift index ec6cb8495..371b7b034 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+TimelineTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+TimelineTests.swift @@ -43,8 +43,7 @@ extension MastodonSDKTests { extension MastodonSDKTests { func testHomeTimeline() { - let domain = "" - let accessToken = "" + let accessToken = testToken guard !domain.isEmpty, !accessToken.isEmpty else { return } let query = Mastodon.API.Timeline.HomeTimelineQuery() diff --git a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift index b32261c34..ad7ee68c5 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift @@ -8,6 +8,9 @@ final class MastodonSDKTests: XCTestCase { let session = URLSession(configuration: .ephemeral) var domain: String { MastodonSDKTests.environmentVariable(key: "domain") } + + // TODO: replace with test account token + var testToken = "" static func environmentVariable(key: String) -> String { return ProcessInfo.processInfo.environment[key]!