From f75f67a42cc054b55de69ab21a675f3c17967dc0 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Tue, 23 Apr 2024 19:10:05 -0700 Subject: [PATCH] Convert several methods to async await. --- .../FeedlyAccountDelegate.swift | 160 +++-------- .../Account/Feedly/FeedlyAPICaller.swift | 270 +++++++----------- 2 files changed, 135 insertions(+), 295 deletions(-) diff --git a/Account/Sources/Account/AccountDelegates/FeedlyAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/FeedlyAccountDelegate.swift index 244365f4a..4b88b6e3f 100644 --- a/Account/Sources/Account/AccountDelegates/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/FeedlyAccountDelegate.swift @@ -300,78 +300,32 @@ final class FeedlyAccountDelegate: AccountDelegate { func renameFolder(for account: Account, with folder: Folder, to name: String) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.renameFolder(for: account, with: folder, to: name) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { guard let id = folder.externalID else { - return DispatchQueue.main.async { - completion(.failure(FeedlyAccountDelegateError.unableToRenameFolder(folder.nameForDisplay, name))) - } + throw FeedlyAccountDelegateError.unableToRenameFolder(folder.nameForDisplay, name) } - + let nameBefore = folder.name - caller.renameCollection(with: id, to: name) { result in - switch result { - case .success(let collection): - folder.name = collection.label - completion(.success(())) - case .failure(let error): - folder.name = nameBefore - completion(.failure(error)) - } + do { + let collection = try await caller.renameCollection(with: id, to: name) + folder.name = collection.label + } catch { + folder.name = nameBefore + throw error } - - folder.name = name } func removeFolder(for account: Account, with folder: Folder) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.removeFolder(for: account, with: folder) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { guard let id = folder.externalID else { - return DispatchQueue.main.async { - completion(.failure(FeedlyAccountDelegateError.unableToRemoveFolder(folder.nameForDisplay))) - } + throw FeedlyAccountDelegateError.unableToRemoveFolder(folder.nameForDisplay) } - let progress = refreshProgress - progress.addToNumberOfTasksAndRemaining(1) - - caller.deleteCollection(with: id) { result in - progress.completeTask() - - switch result { - case .success: - account.removeFolder(folder: folder) - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + refreshProgress.addTask() + defer { refreshProgress.completeTask() } + + try await caller.deleteCollection(with: id) + account.removeFolder(folder: folder) } func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed { @@ -511,84 +465,38 @@ final class FeedlyAccountDelegate: AccountDelegate { func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.removeFeed(for: account, with: feed, from: container) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { guard let folder = container as? Folder, let collectionID = folder.externalID else { - return DispatchQueue.main.async { - completion(.failure(FeedlyAccountDelegateError.unableToRemoveFeed(feed.nameForDisplay))) - } - } - - caller.removeFeed(feed.feedID, fromCollectionWith: collectionID) { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - folder.addFeed(feed) - completion(.failure(error)) - } + throw FeedlyAccountDelegateError.unableToRemoveFeed(feed.nameForDisplay) } + try await caller.removeFeed(feed.feedID, fromCollectionWith: collectionID) folder.removeFeed(feed) } func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws { - try await withCheckedThrowingContinuation { continuation in - self.moveFeed(for: account, with: feed, from: from, to: to) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } + guard let sourceFolder = from as? Folder, let destinationFolder = to as? Folder else { + throw FeedlyAccountDelegateError.addFeedChooseFolder } - } + + // Optimistically move the feed, undoing as appropriate to the failure + sourceFolder.removeFeed(feed) + destinationFolder.addFeed(feed) - @MainActor func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { - guard let from = from as? Folder, let to = to as? Folder else { - return DispatchQueue.main.async { - completion(.failure(FeedlyAccountDelegateError.addFeedChooseFolder)) - } + do { + try await addFeed(for: account, with: feed, to: destinationFolder) + } catch { + destinationFolder.removeFeed(feed) + throw FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed.nameForDisplay, sourceFolder.nameForDisplay, destinationFolder.nameForDisplay) } - - addFeed(for: account, with: feed, to: to) { [weak self] addResult in - switch addResult { - // now that we have added the feed, remove it from the other collection - case .success: - self?.removeFeed(for: account, with: feed, from: from) { removeResult in - switch removeResult { - case .success: - completion(.success(())) - case .failure: - from.addFeed(feed) - completion(.failure(FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed.nameForDisplay, from.nameForDisplay, to.nameForDisplay))) - } - } - case .failure(let error): - from.addFeed(feed) - to.removeFeed(feed) - completion(.failure(error)) - } - + + // Now that we have added the feed, remove it from the source folder + do { + try await removeFeed(for: account, with: feed, from: sourceFolder) + } catch { + sourceFolder.addFeed(feed) + throw FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed.nameForDisplay, sourceFolder.nameForDisplay, destinationFolder.nameForDisplay) } - - // optimistically move the feed, undoing as appropriate to the failure - from.removeFeed(feed) - to.addFeed(feed) } func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws { diff --git a/Account/Sources/Account/Feedly/FeedlyAPICaller.swift b/Account/Sources/Account/Feedly/FeedlyAPICaller.swift index f6e48ca72..e19f82bf4 100644 --- a/Account/Sources/Account/Feedly/FeedlyAPICaller.swift +++ b/Account/Sources/Account/Feedly/FeedlyAPICaller.swift @@ -153,25 +153,11 @@ protocol FeedlyAPICallerDelegate: AnyObject { func importOPML(_ opmlData: Data) async throws { - guard !isSuspended else { - throw TransportError.suspended - } - guard let accessToken = credentials?.secret else { - throw CredentialsError.incompleteCredentials - } + guard !isSuspended else { throw TransportError.suspended } - var components = baseURLComponents - components.path = "/v3/opml" - - guard let url = components.url else { - fatalError("\(components) does not produce a valid URL.") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" + var request = try urlRequest(path: "/v3/opml", method: HTTPMethod.post, addJSONHeaders: false, addOauthToken: true) request.addValue("text/xml", forHTTPHeaderField: HTTPRequestHeader.contentType) - request.addValue("application/json", forHTTPHeaderField: "Accept-Type") - request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) + request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.acceptType) request.httpBody = opmlData let (httpResponse, _) = try await send(request: request, resultType: String.self) @@ -182,25 +168,9 @@ protocol FeedlyAPICallerDelegate: AnyObject { func createCollection(named label: String) async throws -> FeedlyCollection { - guard !isSuspended else { - throw TransportError.suspended - } - guard let accessToken = credentials?.secret else { - throw CredentialsError.incompleteCredentials - } + guard !isSuspended else { throw TransportError.suspended } - var components = baseURLComponents - components.path = "/v3/collections" - - guard let url = components.url else { - fatalError("\(components) does not produce a valid URL.") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) - request.addValue("application/json", forHTTPHeaderField: "Accept-Type") - request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) + var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, addJSONHeaders: true, addOauthToken: true) struct CreateCollectionBody: Encodable { var label: String @@ -216,166 +186,81 @@ protocol FeedlyAPICallerDelegate: AnyObject { } return collection } - - func renameCollection(with id: String, to name: String, completion: @escaping (Result) -> ()) { - guard !isSuspended else { - return DispatchQueue.main.async { - completion(.failure(TransportError.suspended)) - } + + func renameCollection(with id: String, to name: String) async throws -> FeedlyCollection { + + guard !isSuspended else { throw TransportError.suspended } + + var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, addJSONHeaders: true, addOauthToken: true) + + struct RenameCollectionBody: Encodable { + var id: String + var label: String } - - guard let accessToken = credentials?.secret else { - return DispatchQueue.main.async { - completion(.failure(CredentialsError.incompleteCredentials)) - } - } - var components = baseURLComponents - components.path = "/v3/collections" - - guard let url = components.url else { - fatalError("\(components) does not produce a valid URL.") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) - request.addValue("application/json", forHTTPHeaderField: "Accept-Type") - request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - - do { - struct RenameCollectionBody: Encodable { - var id: String - var label: String - } - let encoder = JSONEncoder() - let data = try encoder.encode(RenameCollectionBody(id: id, label: name)) - request.httpBody = data - } catch { - return DispatchQueue.main.async { - completion(.failure(error)) - } - } - - send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in - switch result { - case .success(let (httpResponse, collections)): - if httpResponse.statusCode == 200, let collection = collections?.first { - completion(.success(collection)) - } else { - completion(.failure(URLError(.cannotDecodeContentData))) - } - case .failure(let error): - completion(.failure(error)) - } + let encoder = JSONEncoder() + let data = try encoder.encode(RenameCollectionBody(id: id, label: name)) + request.httpBody = data + + let (httpResponse, collections) = try await send(request: request, resultType: [FeedlyCollection].self) + + guard let collection = collections?.first, httpResponse.statusCode == HTTPResponseCode.OK else { + throw URLError(.cannotDecodeContentData) } + return collection } - + private func encodeForURLPath(_ pathComponent: String) -> String? { return pathComponent.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed) } - func deleteCollection(with id: String, completion: @escaping (Result) -> ()) { - guard !isSuspended else { - return DispatchQueue.main.async { - completion(.failure(TransportError.suspended)) - } - } - - guard let accessToken = credentials?.secret else { - return DispatchQueue.main.async { - completion(.failure(CredentialsError.incompleteCredentials)) - } - } + func deleteCollection(with id: String) async throws { + + guard !isSuspended else { throw TransportError.suspended } + guard let encodedID = encodeForURLPath(id) else { - return DispatchQueue.main.async { - completion(.failure(FeedlyAccountDelegateError.unexpectedResourceID(id))) - } + throw FeedlyAccountDelegateError.unexpectedResourceID(id) } - var components = baseURLComponents - components.percentEncodedPath = "/v3/collections/\(encodedID)" - - guard let url = components.url else { - fatalError("\(components) does not produce a valid URL.") - } - - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) - request.addValue("application/json", forHTTPHeaderField: "Accept-Type") - request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - - send(request: request, resultType: Optional.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in - switch result { - case .success(let (httpResponse, _)): - if httpResponse.statusCode == 200 { - completion(.success(())) - } else { - completion(.failure(URLError(.cannotDecodeContentData))) - } - case .failure(let error): - completion(.failure(error)) - } + let request = try urlRequest(path: "/v3/collections/\(encodedID)", method: HTTPMethod.delete, addJSONHeaders: true, addOauthToken: true) + + let (httpResponse, _) = try await send(request: request, resultType: Optional.self) + + guard httpResponse.statusCode == HTTPResponseCode.OK else { + throw URLError(.cannotDecodeContentData) } } - func removeFeed(_ feedId: String, fromCollectionWith collectionID: String, completion: @escaping (Result) -> ()) { - guard !isSuspended else { - return DispatchQueue.main.async { - completion(.failure(TransportError.suspended)) - } - } - - guard let accessToken = credentials?.secret else { - return DispatchQueue.main.async { - completion(.failure(CredentialsError.incompleteCredentials)) - } - } + func removeFeed(_ feedId: String, fromCollectionWith collectionID: String) async throws { + + guard !isSuspended else { throw TransportError.suspended } guard let encodedCollectionID = encodeForURLPath(collectionID) else { - return DispatchQueue.main.async { - completion(.failure(FeedlyAccountDelegateError.unexpectedResourceID(collectionID))) - } + throw FeedlyAccountDelegateError.unexpectedResourceID(collectionID) } var components = baseURLComponents components.percentEncodedPath = "/v3/collections/\(encodedCollectionID)/feeds/.mdelete" - guard let url = components.url else { fatalError("\(components) does not produce a valid URL.") } var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) - request.addValue("application/json", forHTTPHeaderField: "Accept-Type") - request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - - do { - struct RemovableFeed: Encodable { - let id: String - } - let encoder = JSONEncoder() - let data = try encoder.encode([RemovableFeed(id: feedId)]) - request.httpBody = data - } catch { - return DispatchQueue.main.async { - completion(.failure(error)) - } + request.httpMethod = HTTPMethod.delete + _addJSONHeaders(&request) + try addOauthAccessToken(&request) + + struct RemovableFeed: Encodable { + let id: String } - + let encoder = JSONEncoder() + let data = try encoder.encode([RemovableFeed(id: feedId)]) + request.httpBody = data + // `resultType` is optional because the Feedly API has gone from returning an array of removed feeds to returning `null`. // https://developer.feedly.com/v3/collections/#remove-multiple-feeds-from-a-personal-collection - send(request: request, resultType: Optional<[FeedlyFeed]>.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in - switch result { - case .success((let httpResponse, _)): - if httpResponse.statusCode == 200 { - completion(.success(())) - } else { - completion(.failure(URLError(.cannotDecodeContentData))) - } - case .failure(let error): - completion(.failure(error)) - } + let (httpResponse, _) = try await send(request: request, resultType: Optional<[FeedlyFeed]>.self) + + guard httpResponse.statusCode == HTTPResponseCode.OK else { + throw URLError(.cannotDecodeContentData) } } } @@ -946,3 +831,50 @@ extension FeedlyAPICaller: FeedlyLogoutService { } } } + +private extension FeedlyAPICaller { + + func urlRequest(path: String, method: String, addJSONHeaders: Bool, addOauthToken: Bool) throws -> URLRequest { + + let url = apiURL(path) + var request = URLRequest(url: url) + + request.httpMethod = method + + if addJSONHeaders { + _addJSONHeaders(&request) + } + if addOauthToken { + try addOauthAccessToken(&request) + } + + return request + } + + func _addJSONHeaders(_ request: inout URLRequest) { + + request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) + request.addValue("application/json", forHTTPHeaderField: "Accept-Type") + } + + func addOauthAccessToken(_ request: inout URLRequest) throws { + + guard let accessToken = credentials?.secret else { + throw CredentialsError.incompleteCredentials + } + + request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) + } + + func apiURL(_ path: String) -> URL { + + var components = baseURLComponents + components.path = path + + guard let url = components.url else { + fatalError("\(components) does not produce a valid URL.") + } + + return url + } +}