From 00173e5c30b62970470d5c8b8a6f88722d7bb0b5 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 27 Jan 2021 16:01:20 +0800 Subject: [PATCH 1/3] feat: add Mastodon.API.App.create API --- Mastodon.xcodeproj/project.pbxproj | 4 + .../xcschemes/xcschememanagement.plist | 27 ++++++- Mastodon/Generated/Assets.swift | 6 +- Mastodon/Generated/Strings.swift | 8 +- Mastodon/Mastodon.xctestplan | 31 ++++++++ MastodonSDK.xctestplan | 24 ++++++ .../MastodonSDK/API/Mastodon+API+App.swift | 21 ++++- .../MastodonSDK/API/Mastodon+API.swift | 23 ++++-- .../Response/Mastodon+Response+Content.swift | 79 +++++++++++++++++++ MastodonSDK/Tests/LinuxMain.swift | 7 -- .../MastodonSDKTests/MastodonSDKTests.swift | 35 ++++++-- .../MastodonSDKTests/XCTestManifests.swift | 9 --- 12 files changed, 236 insertions(+), 38 deletions(-) create mode 100644 Mastodon/Mastodon.xctestplan create mode 100644 MastodonSDK.xctestplan create mode 100644 MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift delete mode 100644 MastodonSDK/Tests/LinuxMain.swift delete mode 100644 MastodonSDK/Tests/MastodonSDKTests/XCTestManifests.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 8bb802b6b..4cef307c8 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -150,6 +150,8 @@ DB8AF55625C137A8002E6C99 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; + DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; + DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -254,6 +256,8 @@ DB427DC925BAA00100D1B89D = { isa = PBXGroup; children = ( + DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */, + DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */, DB3D0FED25BAA42200EAA174 /* MastodonSDK */, DB427DD425BAA00100D1B89D /* Mastodon */, DB427DEB25BAA00100D1B89D /* MastodonTests */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index ec856def9..12419edb6 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,35 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 8 + 6 Mastodon.xcscheme_^#shared#^_ orderHint - 9 + 5 + + + SuppressBuildableAutocreation + + DB427DD125BAA00100D1B89D + + primary + + + DB427DE725BAA00100D1B89D + + primary + + + DB427DF225BAA00100D1B89D + + primary + + + DB89B9F525C10FD0008580ED + + primary + diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 9d169beb4..62a06accc 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -64,7 +64,11 @@ internal extension ColorAsset.Color { // swiftlint:disable convenience_type private final class BundleToken { static let bundle: Bundle = { - Bundle(for: BundleToken.self) + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif }() } // swiftlint:enable convenience_type diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c51bbaea9..e06859b3f 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -25,6 +25,12 @@ extension L10n { // swiftlint:disable convenience_type private final class BundleToken { - static let bundle = Bundle(for: BundleToken.self) + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() } // swiftlint:enable convenience_type diff --git a/Mastodon/Mastodon.xctestplan b/Mastodon/Mastodon.xctestplan new file mode 100644 index 000000000..b7693b556 --- /dev/null +++ b/Mastodon/Mastodon.xctestplan @@ -0,0 +1,31 @@ +{ + "configurations" : [ + { + "id" : "63D68260-F74D-4CA6-ADBC-B1263DD6BE55", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Mastodon.xcodeproj", + "identifier" : "DB427DE725BAA00100D1B89D", + "name" : "MastodonTests" + } + }, + { + "target" : { + "containerPath" : "container:Mastodon.xcodeproj", + "identifier" : "DB427DF225BAA00100D1B89D", + "name" : "MastodonUITests" + } + } + ], + "version" : 1 +} diff --git a/MastodonSDK.xctestplan b/MastodonSDK.xctestplan new file mode 100644 index 000000000..14cf031db --- /dev/null +++ b/MastodonSDK.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "5119353D-C795-4264-89FD-8376D9B144F8", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:MastodonSDK", + "identifier" : "MastodonSDKTests", + "name" : "MastodonSDKTests" + } + } + ], + "version" : 1 +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift index c555f5046..6985bbd5c 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift @@ -5,14 +5,22 @@ // Created by xiaojian sun on 2021/1/25. // -import Combine import Foundation +import Combine -public extension Mastodon.API.App { +extension Mastodon.API.App { static func appEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("apps") } + + public static func create( + session: URLSession, + query: CreateQuery + ) -> AnyPublisher, Error> { + fatalError() + } + } @@ -41,13 +49,18 @@ extension Mastodon.API.App { } } - struct CreateAnAppQuery { + struct CreateQuery { public let clientName: String public let redirectURIs: String public let scopes: String? public let website: String? - public init(clientName: String, redirectURIs: String, scopes: String?, website: String?) { + public init( + clientName: String, + redirectURIs: String = "urn:ietf:wg:oauth:2.0:oob", + scopes: String? = "read write follow push", + website: String? + ) { self.clientName = clientName self.redirectURIs = redirectURIs self.scopes = scopes diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index e83f29fb5..b21c44cd0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -9,11 +9,7 @@ import Foundation import NIOHTTP1 public extension Mastodon.API { - - static func endpointURL(domain: String) -> URL { - return URL(string: "https://" + domain + "/api/v1/")! - } - + static let timeoutInterval: TimeInterval = 10 static let decoder: JSONDecoder = { let decoder = JSONDecoder() @@ -21,10 +17,25 @@ public extension Mastodon.API { return decoder }() - static let httpHeaderDateFormatter = ISO8601DateFormatter() +} + +extension Mastodon.API { enum Error { } enum App { } +} + +extension Mastodon.API { + + static func endpointURL(domain: String) -> URL { + return URL(string: "https://" + domain + "/api/v1/")! + } + + static func request( + url: URL + ) -> URLRequest { + fatalError() + } } diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift new file mode 100644 index 000000000..f99614311 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -0,0 +1,79 @@ +// +// Mastodon+Response+Content.swift +// +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import Foundation + +extension Mastodon.Response { + public struct Content { + + // entity + public let value: T + + // standard fields + public let date: Date? + + // application fields + public let rateLimit: RateLimit? + public let responseTime: Int? + + public var networkDate: Date { + return date ?? Date() + } + + public init(value: T, response: URLResponse) { + self.value = value + + self.date = { + guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "date") else { return nil } + return Mastodon.API.httpHeaderDateFormatter.date(from: string) + }() + + self.rateLimit = RateLimit(response: response) + self.responseTime = { + guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil } + return Int(string) + }() + } + + } +} + +extension Mastodon.Response.Content { + public struct RateLimit { + + public let limit: Int + public let remaining: Int + public let reset: Date + + public init(limit: Int, remaining: Int, reset: Date) { + self.limit = limit + self.remaining = remaining + self.reset = reset + } + + public init?(response: URLResponse) { + guard let response = response as? HTTPURLResponse else { + return nil + } + + guard let limitString = response.value(forHTTPHeaderField: "X-RateLimit-Limit"), + let limit = Int(limitString), + let remainingString = response.value(forHTTPHeaderField: "X-RateLimit-Remaining"), + let remaining = Int(remainingString) else { + return nil + } + + guard let resetTimestampString = response.value(forHTTPHeaderField: "X-RateLimit-Reset"), + let reset = Mastodon.API.httpHeaderDateFormatter.date(from: resetTimestampString) else { + return nil + } + + self.init(limit: limit, remaining: remaining, reset: reset) + } + + } +} diff --git a/MastodonSDK/Tests/LinuxMain.swift b/MastodonSDK/Tests/LinuxMain.swift deleted file mode 100644 index 5b06c1211..000000000 --- a/MastodonSDK/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import MastodonSDKTests - -var tests = [XCTestCaseEntry]() -tests += MastodonSDKTests.allTests() -XCTMain(tests) diff --git a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift index ff273005c..73ff16ee8 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift @@ -1,14 +1,33 @@ import XCTest +import Combine @testable import MastodonSDK final class MastodonSDKTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - } + + var disposeBag = Set() + + let domain = "mstdn.jp" + let session = URLSession(configuration: .ephemeral) + + func testCreateAnAnpplication() throws { + let theExpectation = expectation(description: "Create An Application") + + let query = Mastodon.API.App.CreateQuery( + clientName: "XCTest", + website: nil + ) + Mastodon.API.App.create(session: session, query: query) + .receive(on: DispatchQueue.main) + .sink { completion in + + } receiveValue: { response in + theExpectation.fulfill() + } + .store(in: &disposeBag) - static var allTests = [ - ("testExample", testExample), - ] + wait(for: [theExpectation], timeout: 10.0) + } + + + } diff --git a/MastodonSDK/Tests/MastodonSDKTests/XCTestManifests.swift b/MastodonSDK/Tests/MastodonSDKTests/XCTestManifests.swift deleted file mode 100644 index 8685c3172..000000000 --- a/MastodonSDK/Tests/MastodonSDKTests/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(MastodonSDKTests.allTests), - ] -} -#endif From 7ecbcec077755f125173857c30619291acc46084 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 27 Jan 2021 18:46:14 +0800 Subject: [PATCH 2/3] feat: add Toot & User entity --- .../Mastodon+API+Error+MastodonAPIError.swift | 18 ++++ .../API/Error/Mastodon+API+Error.swift | 36 +++++++ .../MastodonSDK/API/Mastodon+API+App.swift | 40 ++++---- .../MastodonSDK/API/Mastodon+API+Error.swift | 22 ----- .../MastodonSDK/API/Mastodon+API+OAuth.swift | 18 ++++ .../API/Mastodon+API+Timeline.swift | 91 +++++++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 99 ++++++++++++++++--- .../Entity/Mastodon+Entity+Toot.swift | 34 +++++++ .../Entity/Mastodon+Entity+User.swift | 31 ++++++ .../Sources/MastodonSDK/Protocol/Query.swift | 16 +++ .../Mastodon+Response+ErrorResponse.swift | 20 ++++ .../MastodonSDKTests/MastodonSDKTests.swift | 14 ++- 12 files changed, 382 insertions(+), 57 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift delete mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Error.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Toot.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+User.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+ErrorResponse.swift diff --git a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift new file mode 100644 index 000000000..47449e79e --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift @@ -0,0 +1,18 @@ +// +// Mastodon+API+Error+MastodonAPIError.swift +// +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import Foundation + +extension Mastodon.API.Error { + public enum MastodonAPIError: Swift.Error { + case generic(errorResponse: Mastodon.Response.ErrorResponse) + + init(errorResponse: Mastodon.Response.ErrorResponse) { + self = .generic(errorResponse: errorResponse) + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift new file mode 100644 index 000000000..ad87fe8f3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift @@ -0,0 +1,36 @@ +// +// Mastodon+API+Error.swift +// +// +// Created by MainasuK Cirno on 2021/1/26. +// + +import Foundation +import enum NIOHTTP1.HTTPResponseStatus + +extension Mastodon.API { + public struct Error: Swift.Error { + + public var httpResponseStatus: HTTPResponseStatus + public var mastodonAPIError: MastodonAPIError? + + init( + httpResponseStatus: HTTPResponseStatus, + mastodonAPIError: Mastodon.API.Error.MastodonAPIError? + ) { + self.httpResponseStatus = httpResponseStatus + self.mastodonAPIError = mastodonAPIError + } + + init( + httpResponseStatus: HTTPResponseStatus, + errorResponse: Mastodon.Response.ErrorResponse + ) { + self.init( + httpResponseStatus: httpResponseStatus, + mastodonAPIError: MastodonAPIError(errorResponse: errorResponse) + ) + } + + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift index 6985bbd5c..78fd2a39f 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift @@ -16,17 +16,27 @@ extension Mastodon.API.App { public static func create( session: URLSession, + domain: String, query: CreateQuery ) -> AnyPublisher, Error> { - fatalError() + let request = Mastodon.API.request( + url: appEndpointURL(domain: domain), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Application.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() } - } extension Mastodon.API.App { - struct Application: Codable { + public struct Application: Codable { public let id: String @@ -49,12 +59,19 @@ extension Mastodon.API.App { } } - struct CreateQuery { + public struct CreateQuery: Codable, PostQuery { public let clientName: String public let redirectURIs: String public let scopes: String? public let website: String? + enum CodingKeys: String, CodingKey { + case clientName = "client_name" + case redirectURIs = "redirect_uris" + case scopes + case website + } + public init( clientName: String, redirectURIs: String = "urn:ietf:wg:oauth:2.0:oob", @@ -67,19 +84,8 @@ extension Mastodon.API.App { self.website = website } - var queryItems: [URLQueryItem]? { - var items: [URLQueryItem] = [] - items.append(URLQueryItem(name: "client_name", value: clientName)) - items.append(URLQueryItem(name: "redirect_uris", value: redirectURIs)) - scopes.flatMap { - items.append(URLQueryItem(name: "scopes", value: $0)) - } - website.flatMap { - items.append(URLQueryItem(name: "website", value: $0)) - } - - guard !items.isEmpty else { return nil } - return items + var body: Data? { + return try? Mastodon.API.encoder.encode(self) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Error.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Error.swift deleted file mode 100644 index 7a6c5e899..000000000 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Error.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Mastodon+API+Error.swift -// -// -// Created by MainasuK Cirno on 2021/1/26. -// - -import Foundation - -extension Mastodon.API.Error { - - struct ErrorResponse: Codable { - let error: String - let errorDescription: String? - - enum CodingKeys: String, CodingKey { - case error - case errorDescription = "error_description" - } - } - -} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift new file mode 100644 index 000000000..88461f995 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift @@ -0,0 +1,18 @@ +// +// Mastodon+API+OAuth.swift +// +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import Foundation + +extension Mastodon.API.OAuth { + + public static let authorizationField = "Authorization" + + public struct Authorization { + public let accessToken: String + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift new file mode 100644 index 000000000..944aad7ec --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -0,0 +1,91 @@ +// +// Mastodon+API+Timeline.swift +// +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import Foundation +import Combine + +extension Mastodon.API.Timeline { + + static func publicTimelineEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/public") + } + + public static func create( + session: URLSession, + domain: String, + query: PublicTimelineQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.request( + url: publicTimelineEndpointURL(domain: domain), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Toot].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Timeline { + public struct PublicTimelineQuery: Codable, GetQuery { + + public let local: Bool? + public let remote: Bool? + public let onlyMedia: Bool? + public let maxID: Mastodon.Entity.Toot.ID? + public let sinceID: Mastodon.Entity.Toot.ID? + public let minID: Mastodon.Entity.Toot.ID? + public let limit: Int? + + public init(local: Bool?, remote: Bool?, onlyMedia: Bool?, maxID: Mastodon.Entity.Toot.ID?, sinceID: Mastodon.Entity.Toot.ID?, minID: Mastodon.Entity.Toot.ID?, limit: Int?) { + self.local = local + self.remote = remote + self.onlyMedia = onlyMedia + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + local.flatMap { + items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) + } + remote.flatMap { + items.append(URLQueryItem(name: "remote", value: $0.queryItemValue)) + } + onlyMedia.flatMap { + items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) + } + maxID.flatMap { + items.append(URLQueryItem(name: "max_id", value: $0)) + } + sinceID.flatMap { + items.append(URLQueryItem(name: "since_id", value: $0)) + } + minID.flatMap { + items.append(URLQueryItem(name: "min_id", value: $0)) + } + limit.flatMap { + items.append(URLQueryItem(name: "limit", value: String($0))) + } + guard !items.isEmpty else { return nil } + return items + } + } +} + +extension Bool { + var queryItemValue: String { + return self ? "true" : "false" + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index b21c44cd0..e8ea282f8 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -6,36 +6,107 @@ // import Foundation -import NIOHTTP1 +import enum NIOHTTP1.HTTPResponseStatus -public extension Mastodon.API { +extension Mastodon.API { static let timeoutInterval: TimeInterval = 10 + static let httpHeaderDateFormatter = ISO8601DateFormatter() + static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder }() - static let httpHeaderDateFormatter = ISO8601DateFormatter() - -} - -extension Mastodon.API { - enum Error { } - enum App { } -} - -extension Mastodon.API { static func endpointURL(domain: String) -> URL { return URL(string: "https://" + domain + "/api/v1/")! } +} + +extension Mastodon.API { + public enum App { } + public enum OAuth { } + public enum Timeline { } +} + +extension Mastodon.API { + static func request( - url: URL + url: URL, + query: GetQuery, + authorization: OAuth.Authorization? ) -> URLRequest { - fatalError() + var components = URLComponents(string: url.absoluteString)! + 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 request( + url: URL, + query: PostQuery, + authorization: OAuth.Authorization? + ) -> URLRequest { + let components = URLComponents(string: url.absoluteString)! + let requestURL = components.url! + var request = URLRequest( + url: requestURL, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: Mastodon.API.timeoutInterval + ) + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.httpBody = query.body + if let authorization = authorization { + request.setValue( + "Bearer \(authorization.accessToken)", + forHTTPHeaderField: Mastodon.API.OAuth.authorizationField + ) + } + request.httpMethod = "POST" + return request + } + + static func decode(type: T.Type, from data: Data, response: URLResponse) throws -> T where T : Decodable { + // decode data then decode error if could + do { + return try Mastodon.API.decoder.decode(type, from: data) + } catch let decodeError { + #if DEBUG + debugPrint(decodeError) + #endif + + guard let httpURLResponse = response as? HTTPURLResponse else { + assertionFailure() + throw decodeError + } + + let httpResponseStatus = HTTPResponseStatus(statusCode: httpURLResponse.statusCode) + if let errorResponse = try? Mastodon.API.decoder.decode(Mastodon.Response.ErrorResponse.self, from: data) { + throw Mastodon.API.Error(httpResponseStatus: httpResponseStatus, errorResponse: errorResponse) + } + + throw Mastodon.API.Error(httpResponseStatus: httpResponseStatus, mastodonAPIError: nil) + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Toot.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Toot.swift new file mode 100644 index 000000000..3bf52991f --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Toot.swift @@ -0,0 +1,34 @@ +// +// Mastodon+Entity+Toot.swift +// +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import Foundation + +extension Mastodon.Entity { + public struct Toot: Codable { + + public typealias ID = String + + public let id: ID + + public let createdAt: Date + public let content: String + public let account: User + + public let language: String + public let visibility: String + + enum CodingKeys: String, CodingKey { + case id + case createdAt = "created_at" + case content + case account + case language + case visibility + } + + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+User.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+User.swift new file mode 100644 index 000000000..276ca8e4e --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+User.swift @@ -0,0 +1,31 @@ +// +// Mastodon+Entity+User.swift +// +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import Foundation + +extension Mastodon.Entity { + public struct User: Codable { + + public typealias ID = String + + public let id: ID + + public let username: Date + public let acct: String + public let displayName: String? + public let avatar: String? + + enum CodingKeys: String, CodingKey { + case id + case username + case acct + case displayName = "display_name" + case avatar + } + + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift b/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift new file mode 100644 index 000000000..e95b9141d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift @@ -0,0 +1,16 @@ +// +// Query.swift +// +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import Foundation + +protocol GetQuery { + var queryItems: [URLQueryItem]? { get } +} + +protocol PostQuery { + var body: Data? { get } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+ErrorResponse.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+ErrorResponse.swift new file mode 100644 index 000000000..48519a5b6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+ErrorResponse.swift @@ -0,0 +1,20 @@ +// +// Mastodon+Response+ErrorResponse.swift +// +// +// Created by MainasuK Cirno on 2021/1/27. +// + +import Foundation + +extension Mastodon.Response { + public struct ErrorResponse: Codable { + public let error: String + public let errorDescription: String? + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + } + } +} diff --git a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift index 73ff16ee8..db8f0d315 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift @@ -16,11 +16,19 @@ final class MastodonSDKTests: XCTestCase { clientName: "XCTest", website: nil ) - Mastodon.API.App.create(session: session, query: query) + Mastodon.API.App.create(session: session, domain: domain, query: query) .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.name, "XCTest") + XCTAssertEqual(response.value.website, nil) + XCTAssertEqual(response.value.redirectURI, "urn:ietf:wg:oauth:2.0:oob") theExpectation.fulfill() } .store(in: &disposeBag) @@ -28,6 +36,4 @@ final class MastodonSDKTests: XCTestCase { wait(for: [theExpectation], timeout: 10.0) } - - } From 27a7ccbd885fa803430a6d12d7c94ef28685fcad Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 27 Jan 2021 18:52:01 +0800 Subject: [PATCH 3/3] feat: make Unit Test works --- .../API/Mastodon+API+Timeline.swift | 12 ++++++++-- .../MastodonSDK/API/Mastodon+API.swift | 13 ++++++++++- .../Entity/Mastodon+Entity+User.swift | 2 +- .../MastodonSDKTests/MastodonSDKTests.swift | 23 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 944aad7ec..eb9271b02 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -14,7 +14,7 @@ extension Mastodon.API.Timeline { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/public") } - public static func create( + public static func `public`( session: URLSession, domain: String, query: PublicTimelineQuery @@ -45,7 +45,15 @@ extension Mastodon.API.Timeline { public let minID: Mastodon.Entity.Toot.ID? public let limit: Int? - public init(local: Bool?, remote: Bool?, onlyMedia: Bool?, maxID: Mastodon.Entity.Toot.ID?, sinceID: Mastodon.Entity.Toot.ID?, minID: Mastodon.Entity.Toot.ID?, limit: Int?) { + public init( + local: Bool? = nil, + remote: Bool? = nil, + onlyMedia: Bool? = nil, + maxID: Mastodon.Entity.Toot.ID? = nil, + sinceID: Mastodon.Entity.Toot.ID? = nil, + minID: Mastodon.Entity.Toot.ID? = nil, + limit: Int? = nil + ) { self.local = local self.remote = remote self.onlyMedia = onlyMedia diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index e8ea282f8..f86e1fc71 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -19,7 +19,18 @@ extension Mastodon.API { }() static let decoder: JSONDecoder = { let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 + decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.custom { decoder throws -> Date in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions.insert(.withFractionalSeconds) + if let date = formatter.date(from: string) { + return date + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)") + } return decoder }() diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+User.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+User.swift index 276ca8e4e..f1dd4f692 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+User.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+User.swift @@ -14,7 +14,7 @@ extension Mastodon.Entity { public let id: ID - public let username: Date + public let username: String public let acct: String public let displayName: String? public let avatar: String? diff --git a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift index db8f0d315..380c7dd41 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift @@ -36,4 +36,27 @@ final class MastodonSDKTests: XCTestCase { wait(for: [theExpectation], timeout: 10.0) } + func testPublicTimeline() throws { + let theExpectation = expectation(description: "Create An Application") + + let query = Mastodon.API.Timeline.PublicTimelineQuery() + Mastodon.API.Timeline.public(session: session, domain: domain, query: query) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + XCTFail(error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + XCTAssert(!response.value.isEmpty) + theExpectation.fulfill() + } + .store(in: &disposeBag) + + wait(for: [theExpectation], timeout: 10.0) + + } + }