mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-29 03:21:02 +00:00
Refactoring
This commit is contained in:
parent
f02b1e033a
commit
8229eecc3a
9 changed files with 131 additions and 125 deletions
|
@ -6,10 +6,11 @@ import HTTP
|
||||||
import Mastodon
|
import Mastodon
|
||||||
|
|
||||||
public final class MastodonAPIClient: HTTPClient {
|
public final class MastodonAPIClient: HTTPClient {
|
||||||
public var instanceURL: URL?
|
public var instanceURL: URL
|
||||||
public var accessToken: String?
|
public var accessToken: String?
|
||||||
|
|
||||||
public required init(session: Session) {
|
public required init(session: Session, instanceURL: URL) {
|
||||||
|
self.instanceURL = instanceURL
|
||||||
super.init(session: session, decoder: MastodonDecoder())
|
super.init(session: session, decoder: MastodonDecoder())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,11 +21,7 @@ public final class MastodonAPIClient: HTTPClient {
|
||||||
|
|
||||||
extension MastodonAPIClient {
|
extension MastodonAPIClient {
|
||||||
public func request<E: Endpoint>(_ endpoint: E) -> AnyPublisher<E.ResultType, Error> {
|
public func request<E: Endpoint>(_ endpoint: E) -> AnyPublisher<E.ResultType, Error> {
|
||||||
guard let instanceURL = instanceURL else {
|
super.request(
|
||||||
return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.request(
|
|
||||||
MastodonAPITarget(baseURL: instanceURL, endpoint: endpoint, accessToken: accessToken),
|
MastodonAPITarget(baseURL: instanceURL, endpoint: endpoint, accessToken: accessToken),
|
||||||
decodeErrorsAs: APIError.self)
|
decodeErrorsAs: APIError.self)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,29 +36,25 @@ public extension AllIdentitiesService {
|
||||||
database.createIdentity(id: id, url: instanceURL)
|
database.createIdentity(id: id, url: instanceURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Never, Error> {
|
func authorizeAndCreateIdentity(id: UUID, url: URL) -> AnyPublisher<Never, Error> {
|
||||||
|
AuthenticationService(url: url, environment: environment)
|
||||||
|
.authenticate()
|
||||||
|
.tryMap {
|
||||||
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
||||||
let authenticationService = AuthenticationService(environment: environment)
|
|
||||||
|
|
||||||
return authenticationService.authorizeApp(instanceURL: instanceURL)
|
try secrets.setInstanceURL(url)
|
||||||
.tryMap { appAuthorization -> (URL, AppAuthorization) in
|
try secrets.setClientID($0.clientId)
|
||||||
try secrets.setInstanceURL(instanceURL)
|
try secrets.setClientSecret($0.clientSecret)
|
||||||
try secrets.setClientID(appAuthorization.clientId)
|
try secrets.setAccessToken($1.accessToken)
|
||||||
try secrets.setClientSecret(appAuthorization.clientSecret)
|
|
||||||
|
|
||||||
return (instanceURL, appAuthorization)
|
|
||||||
}
|
}
|
||||||
.flatMap(authenticationService.authenticate(instanceURL:appAuthorization:))
|
.flatMap { database.createIdentity(id: id, url: url) }
|
||||||
.tryMap { try secrets.setAccessToken($0.accessToken) }
|
|
||||||
.ignoreOutput()
|
.ignoreOutput()
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteIdentity(_ identity: Identity) -> AnyPublisher<Never, Error> {
|
func deleteIdentity(_ identity: Identity) -> AnyPublisher<Never, Error> {
|
||||||
let secrets = Secrets(identityID: identity.id, keychain: environment.keychain)
|
let secrets = Secrets(identityID: identity.id, keychain: environment.keychain)
|
||||||
let mastodonAPIClient = MastodonAPIClient(session: environment.session)
|
let mastodonAPIClient = MastodonAPIClient(session: environment.session, instanceURL: identity.url)
|
||||||
|
|
||||||
mastodonAPIClient.instanceURL = identity.url
|
|
||||||
|
|
||||||
return database.deleteIdentity(id: identity.id)
|
return database.deleteIdentity(id: identity.id)
|
||||||
.collect()
|
.collect()
|
||||||
|
|
|
@ -5,69 +5,33 @@ import Foundation
|
||||||
import Mastodon
|
import Mastodon
|
||||||
import MastodonAPI
|
import MastodonAPI
|
||||||
|
|
||||||
public struct AuthenticationService {
|
public enum AuthenticationError: Error {
|
||||||
|
case canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthenticationService {
|
||||||
private let mastodonAPIClient: MastodonAPIClient
|
private let mastodonAPIClient: MastodonAPIClient
|
||||||
private let webAuthSessionType: WebAuthSession.Type
|
private let webAuthSessionType: WebAuthSession.Type
|
||||||
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
|
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
|
||||||
|
|
||||||
public init(environment: AppEnvironment) {
|
init(url: URL, environment: AppEnvironment) {
|
||||||
mastodonAPIClient = MastodonAPIClient(session: environment.session)
|
mastodonAPIClient = MastodonAPIClient(session: environment.session, instanceURL: url)
|
||||||
webAuthSessionType = environment.webAuthSessionType
|
webAuthSessionType = environment.webAuthSessionType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension AuthenticationService {
|
extension AuthenticationService {
|
||||||
func authorizeApp(instanceURL: URL) -> AnyPublisher<AppAuthorization, Error> {
|
func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
|
||||||
let endpoint = AppAuthorizationEndpoint.apps(
|
let appAuthorization = mastodonAPIClient.request(
|
||||||
|
AppAuthorizationEndpoint.apps(
|
||||||
clientName: OAuth.clientName,
|
clientName: OAuth.clientName,
|
||||||
redirectURI: OAuth.callbackURL.absoluteString,
|
redirectURI: OAuth.callbackURL.absoluteString,
|
||||||
scopes: OAuth.scopes,
|
scopes: OAuth.scopes,
|
||||||
website: OAuth.website)
|
website: OAuth.website))
|
||||||
let target = MastodonAPITarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
|
.share()
|
||||||
|
|
||||||
return mastodonAPIClient.request(target)
|
return appAuthorization
|
||||||
}
|
.zip(appAuthorization.flatMap(authenticate(appAuthorization:)))
|
||||||
|
|
||||||
func authenticate(instanceURL: URL, appAuthorization: AppAuthorization) -> AnyPublisher<AccessToken, Error> {
|
|
||||||
guard let authorizationURL = authorizationURL(
|
|
||||||
instanceURL: instanceURL,
|
|
||||||
clientID: appAuthorization.clientId) else {
|
|
||||||
return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
return webAuthSessionType.publisher(
|
|
||||||
url: authorizationURL,
|
|
||||||
callbackURLScheme: OAuth.callbackURLScheme,
|
|
||||||
presentationContextProvider: webAuthSessionContextProvider)
|
|
||||||
.tryCatch { error -> AnyPublisher<URL?, Error> in
|
|
||||||
if (error as? WebAuthSessionError)?.code == .canceledLogin {
|
|
||||||
return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
.compactMap { $0 }
|
|
||||||
.tryMap { url -> String in
|
|
||||||
guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems,
|
|
||||||
let code = queryItems.first(where: {
|
|
||||||
$0.name == OAuth.codeCallbackQueryItemName
|
|
||||||
})?.value
|
|
||||||
else { throw OAuthError.codeNotFound }
|
|
||||||
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
.flatMap { code -> AnyPublisher<AccessToken, Error> in
|
|
||||||
let endpoint = AccessTokenEndpoint.oauthToken(
|
|
||||||
clientID: appAuthorization.clientId,
|
|
||||||
clientSecret: appAuthorization.clientSecret,
|
|
||||||
code: code,
|
|
||||||
grantType: OAuth.grantType,
|
|
||||||
scopes: OAuth.scopes,
|
|
||||||
redirectURI: OAuth.callbackURL.absoluteString)
|
|
||||||
let target = MastodonAPITarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
|
|
||||||
|
|
||||||
return mastodonAPIClient.request(target)
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,19 +51,66 @@ private extension AuthenticationService {
|
||||||
case codeNotFound
|
case codeNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorizationURL(instanceURL: URL, clientID: String) -> URL? {
|
static func extractCode(oauthCallbackURL: URL) throws -> String {
|
||||||
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
|
guard let queryItems = URLComponents(
|
||||||
return nil
|
url: oauthCallbackURL,
|
||||||
|
resolvingAgainstBaseURL: true)?.queryItems,
|
||||||
|
let code = queryItems.first(where: {
|
||||||
|
$0.name == OAuth.codeCallbackQueryItemName
|
||||||
|
})?.value
|
||||||
|
else { throw OAuthError.codeNotFound }
|
||||||
|
|
||||||
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authorizationURL(appAuthorization: AppAuthorization) throws -> URL {
|
||||||
|
guard var authorizationURLComponents = URLComponents(
|
||||||
|
url: mastodonAPIClient.instanceURL,
|
||||||
|
resolvingAgainstBaseURL: true)
|
||||||
|
else { throw URLError(.badURL) }
|
||||||
|
|
||||||
authorizationURLComponents.path = "/oauth/authorize"
|
authorizationURLComponents.path = "/oauth/authorize"
|
||||||
authorizationURLComponents.queryItems = [
|
authorizationURLComponents.queryItems = [
|
||||||
"client_id": clientID,
|
.init(name: "client_id", value: appAuthorization.clientId),
|
||||||
"scope": OAuth.scopes,
|
.init(name: "scope", value: OAuth.scopes),
|
||||||
"response_type": "code",
|
.init(name: "response_type", value: "code"),
|
||||||
"redirect_uri": OAuth.callbackURL.absoluteString
|
.init(name: "redirect_uri", value: OAuth.callbackURL.absoluteString)
|
||||||
].map { URLQueryItem(name: $0, value: $1) }
|
]
|
||||||
|
|
||||||
return authorizationURLComponents.url
|
guard let authorizationURL = authorizationURLComponents.url else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return authorizationURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticate(appAuthorization: AppAuthorization) -> AnyPublisher<AccessToken, Error> {
|
||||||
|
Just(appAuthorization)
|
||||||
|
.tryMap(authorizationURL(appAuthorization:))
|
||||||
|
.flatMap {
|
||||||
|
webAuthSessionType.publisher(
|
||||||
|
url: $0,
|
||||||
|
callbackURLScheme: OAuth.callbackURLScheme,
|
||||||
|
presentationContextProvider: webAuthSessionContextProvider)
|
||||||
|
}
|
||||||
|
.mapError { error -> Error in
|
||||||
|
if (error as? WebAuthSessionError)?.code == .canceledLogin {
|
||||||
|
return AuthenticationError.canceled as Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
.tryMap(Self.extractCode(oauthCallbackURL:))
|
||||||
|
.flatMap {
|
||||||
|
mastodonAPIClient.request(
|
||||||
|
AccessTokenEndpoint.oauthToken(
|
||||||
|
clientID: appAuthorization.clientId,
|
||||||
|
clientSecret: appAuthorization.clientSecret,
|
||||||
|
code: $0,
|
||||||
|
grantType: OAuth.grantType,
|
||||||
|
scopes: OAuth.scopes,
|
||||||
|
redirectURI: OAuth.callbackURL.absoluteString))
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,8 +24,8 @@ public struct IdentityService {
|
||||||
secrets = Secrets(
|
secrets = Secrets(
|
||||||
identityID: id,
|
identityID: id,
|
||||||
keychain: environment.keychain)
|
keychain: environment.keychain)
|
||||||
mastodonAPIClient = MastodonAPIClient(session: environment.session)
|
mastodonAPIClient = MastodonAPIClient(session: environment.session,
|
||||||
mastodonAPIClient.instanceURL = try secrets.getInstanceURL()
|
instanceURL: try secrets.getInstanceURL())
|
||||||
mastodonAPIClient.accessToken = try? secrets.getAccessToken()
|
mastodonAPIClient.accessToken = try? secrets.getAccessToken()
|
||||||
|
|
||||||
contentDatabase = try ContentDatabase(identityID: id,
|
contentDatabase = try ContentDatabase(identityID: id,
|
||||||
|
|
|
@ -16,8 +16,8 @@ extension WebAuthSession {
|
||||||
static func publisher(
|
static func publisher(
|
||||||
url: URL,
|
url: URL,
|
||||||
callbackURLScheme: String?,
|
callbackURLScheme: String?,
|
||||||
presentationContextProvider: WebAuthPresentationContextProviding) -> AnyPublisher<URL?, Error> {
|
presentationContextProvider: WebAuthPresentationContextProviding) -> AnyPublisher<URL, Error> {
|
||||||
Future<URL?, Error> { promise in
|
Future<URL, Error> { promise in
|
||||||
let webAuthSession = Self(
|
let webAuthSession = Self(
|
||||||
url: url,
|
url: url,
|
||||||
callbackURLScheme: callbackURLScheme) { oauthCallbackURL, error in
|
callbackURLScheme: callbackURLScheme) { oauthCallbackURL, error in
|
||||||
|
@ -25,6 +25,10 @@ extension WebAuthSession {
|
||||||
return promise(.failure(error))
|
return promise(.failure(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let oauthCallbackURL = oauthCallbackURL else {
|
||||||
|
return promise(.failure(URLError(.unknown)))
|
||||||
|
}
|
||||||
|
|
||||||
return promise(.success(oauthCallbackURL))
|
return promise(.success(oauthCallbackURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,11 +18,11 @@ public extension AppEnvironment {
|
||||||
fixtureDatabase: IdentityDatabase? = nil) -> Self {
|
fixtureDatabase: IdentityDatabase? = nil) -> Self {
|
||||||
AppEnvironment(
|
AppEnvironment(
|
||||||
session: Session(configuration: .stubbing),
|
session: Session(configuration: .stubbing),
|
||||||
webAuthSessionType: SuccessfulMockWebAuthSession.self,
|
webAuthSessionType: webAuthSessionType,
|
||||||
keychain: MockKeychain.self,
|
keychain: keychain,
|
||||||
userDefaults: MockUserDefaults(),
|
userDefaults: userDefaults,
|
||||||
userNotificationClient: .mock,
|
userNotificationClient: userNotificationClient,
|
||||||
inMemoryContent: true,
|
inMemoryContent: inMemoryContent,
|
||||||
fixtureDatabase: fixtureDatabase)
|
fixtureDatabase: fixtureDatabase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,20 +8,12 @@ import XCTest
|
||||||
|
|
||||||
class AuthenticationServiceTests: XCTestCase {
|
class AuthenticationServiceTests: XCTestCase {
|
||||||
func testAuthentication() throws {
|
func testAuthentication() throws {
|
||||||
let sut = AuthenticationService(environment: .mock())
|
let sut = AuthenticationService(url: URL(string: "https://mastodon.social")!, environment: .mock())
|
||||||
let instanceURL = URL(string: "https://mastodon.social")!
|
let authenticationRecorder = sut.authenticate().record()
|
||||||
let appAuthorizationRecorder = sut.authorizeApp(instanceURL: instanceURL).record()
|
let (appAuthorization, accessToken) = try wait(for: authenticationRecorder.next(), timeout: 1)
|
||||||
let appAuthorization = try wait(for: appAuthorizationRecorder.next(), timeout: 1)
|
|
||||||
|
|
||||||
XCTAssertEqual(appAuthorization.clientId, "AUTHORIZATION_CLIENT_ID_STUB_VALUE")
|
XCTAssertEqual(appAuthorization.clientId, "AUTHORIZATION_CLIENT_ID_STUB_VALUE")
|
||||||
XCTAssertEqual(appAuthorization.clientSecret, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE")
|
XCTAssertEqual(appAuthorization.clientSecret, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE")
|
||||||
|
|
||||||
let accessTokenRecorder = sut.authenticate(
|
|
||||||
instanceURL: instanceURL,
|
|
||||||
appAuthorization: appAuthorization)
|
|
||||||
.record()
|
|
||||||
let accessToken = try wait(for: accessTokenRecorder.next(), timeout: 1)
|
|
||||||
|
|
||||||
XCTAssertEqual(accessToken.accessToken, "ACCESS_TOKEN_STUB_VALUE")
|
XCTAssertEqual(accessToken.accessToken, "ACCESS_TOKEN_STUB_VALUE")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,12 +11,12 @@ public final class AddIdentityViewModel: ObservableObject {
|
||||||
public let addedIdentityID: AnyPublisher<UUID, Never>
|
public let addedIdentityID: AnyPublisher<UUID, Never>
|
||||||
|
|
||||||
private let allIdentitiesService: AllIdentitiesService
|
private let allIdentitiesService: AllIdentitiesService
|
||||||
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
|
private let addedIdentityIDSubject = PassthroughSubject<UUID, Never>()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(allIdentitiesService: AllIdentitiesService) {
|
init(allIdentitiesService: AllIdentitiesService) {
|
||||||
self.allIdentitiesService = allIdentitiesService
|
self.allIdentitiesService = allIdentitiesService
|
||||||
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
|
addedIdentityID = addedIdentityIDSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,22 +33,26 @@ public extension AddIdentityViewModel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
allIdentitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL)
|
allIdentitiesService.authorizeAndCreateIdentity(id: identityID, url: instanceURL)
|
||||||
.collect()
|
|
||||||
.map { _ in (identityID, instanceURL) }
|
|
||||||
.flatMap(allIdentitiesService.createIdentity(id:instanceURL:))
|
|
||||||
.mapError {
|
|
||||||
return $0
|
|
||||||
}
|
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
.catch { [weak self] error -> Empty<Never, Never> in
|
||||||
.handleEvents(
|
if case AuthenticationError.canceled = error {
|
||||||
receiveSubscription: { [weak self] _ in self?.loading = true },
|
// no-op
|
||||||
receiveCompletion: { [weak self] _ in self?.loading = false })
|
} else {
|
||||||
.sink { [weak self] in
|
self?.alertItem = AlertItem(error: error)
|
||||||
guard let self = self, case .finished = $0 else { return }
|
}
|
||||||
|
|
||||||
self.addedIdentityIDInput.send(identityID)
|
return Empty()
|
||||||
|
}
|
||||||
|
.handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true })
|
||||||
|
.sink { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
self.loading = false
|
||||||
|
|
||||||
|
if case .finished = $0 {
|
||||||
|
self.addedIdentityIDSubject.send(identityID)
|
||||||
|
}
|
||||||
} receiveValue: { _ in }
|
} receiveValue: { _ in }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
@ -71,7 +75,7 @@ public extension AddIdentityViewModel {
|
||||||
.sink { [weak self] in
|
.sink { [weak self] in
|
||||||
guard let self = self, case .finished = $0 else { return }
|
guard let self = self, case .finished = $0 else { return }
|
||||||
|
|
||||||
self.addedIdentityIDInput.send(identityID)
|
self.addedIdentityIDSubject.send(identityID)
|
||||||
} receiveValue: { _ in }
|
} receiveValue: { _ in }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,10 @@ extension Publisher {
|
||||||
to keyPath: ReferenceWritableKeyPath<Root, AlertItem?>,
|
to keyPath: ReferenceWritableKeyPath<Root, AlertItem?>,
|
||||||
on object: Root) -> AnyPublisher<Output, Never> {
|
on object: Root) -> AnyPublisher<Output, Never> {
|
||||||
self.catch { [weak object] error -> Empty<Output, Never> in
|
self.catch { [weak object] error -> Empty<Output, Never> in
|
||||||
|
if let object = object {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
object?[keyPath: keyPath] = AlertItem(error: error)
|
object[keyPath: keyPath] = AlertItem(error: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Empty()
|
return Empty()
|
||||||
|
|
Loading…
Reference in a new issue