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) } - - }