Refactoring

This commit is contained in:
Justin Mazzocchi 2020-09-08 18:02:55 -07:00
parent f02b1e033a
commit 8229eecc3a
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
9 changed files with 131 additions and 125 deletions

View file

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

View file

@ -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> {
let secrets = Secrets(identityID: id, keychain: environment.keychain) AuthenticationService(url: url, environment: environment)
let authenticationService = AuthenticationService(environment: environment) .authenticate()
.tryMap {
let secrets = Secrets(identityID: id, keychain: environment.keychain)
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()

View file

@ -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(
clientName: OAuth.clientName, AppAuthorizationEndpoint.apps(
redirectURI: OAuth.callbackURL.absoluteString, clientName: OAuth.clientName,
scopes: OAuth.scopes, redirectURI: OAuth.callbackURL.absoluteString,
website: OAuth.website) scopes: OAuth.scopes,
let target = MastodonAPITarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) website: OAuth.website))
.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()
} }
} }

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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
DispatchQueue.main.async { if let object = object {
object?[keyPath: keyPath] = AlertItem(error: error) DispatchQueue.main.async {
object[keyPath: keyPath] = AlertItem(error: error)
}
} }
return Empty() return Empty()