Convert several methods to async await.

This commit is contained in:
Brent Simmons 2024-04-23 19:10:05 -07:00
parent d2ae1d3120
commit f75f67a42c
2 changed files with 135 additions and 295 deletions

View File

@ -300,78 +300,32 @@ final class FeedlyAccountDelegate: AccountDelegate {
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws { 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, Error>) -> Void) {
guard let id = folder.externalID else { guard let id = folder.externalID else {
return DispatchQueue.main.async { throw FeedlyAccountDelegateError.unableToRenameFolder(folder.nameForDisplay, name)
completion(.failure(FeedlyAccountDelegateError.unableToRenameFolder(folder.nameForDisplay, name)))
}
} }
let nameBefore = folder.name let nameBefore = folder.name
caller.renameCollection(with: id, to: name) { result in do {
switch result { let collection = try await caller.renameCollection(with: id, to: name)
case .success(let collection): folder.name = collection.label
folder.name = collection.label } catch {
completion(.success(())) folder.name = nameBefore
case .failure(let error): throw error
folder.name = nameBefore
completion(.failure(error))
}
} }
folder.name = name
} }
func removeFolder(for account: Account, with folder: Folder) async throws { 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, Error>) -> Void) {
guard let id = folder.externalID else { guard let id = folder.externalID else {
return DispatchQueue.main.async { throw FeedlyAccountDelegateError.unableToRemoveFolder(folder.nameForDisplay)
completion(.failure(FeedlyAccountDelegateError.unableToRemoveFolder(folder.nameForDisplay)))
}
} }
let progress = refreshProgress refreshProgress.addTask()
progress.addToNumberOfTasksAndRemaining(1) defer { refreshProgress.completeTask() }
caller.deleteCollection(with: id) { result in try await caller.deleteCollection(with: id)
progress.completeTask() account.removeFolder(folder: folder)
switch result {
case .success:
account.removeFolder(folder: folder)
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
} }
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed { 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 { 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, Error>) -> Void) {
guard let folder = container as? Folder, let collectionID = folder.externalID else { guard let folder = container as? Folder, let collectionID = folder.externalID else {
return DispatchQueue.main.async { throw FeedlyAccountDelegateError.unableToRemoveFeed(feed.nameForDisplay)
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))
}
} }
try await caller.removeFeed(feed.feedID, fromCollectionWith: collectionID)
folder.removeFeed(feed) folder.removeFeed(feed)
} }
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws { func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws {
try await withCheckedThrowingContinuation { continuation in guard let sourceFolder = from as? Folder, let destinationFolder = to as? Folder else {
self.moveFeed(for: account, with: feed, from: from, to: to) { result in throw FeedlyAccountDelegateError.addFeedChooseFolder
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
} }
}
// 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, Error>) -> Void) { do {
guard let from = from as? Folder, let to = to as? Folder else { try await addFeed(for: account, with: feed, to: destinationFolder)
return DispatchQueue.main.async { } catch {
completion(.failure(FeedlyAccountDelegateError.addFeedChooseFolder)) destinationFolder.removeFeed(feed)
} throw FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed.nameForDisplay, sourceFolder.nameForDisplay, destinationFolder.nameForDisplay)
} }
addFeed(for: account, with: feed, to: to) { [weak self] addResult in // Now that we have added the feed, remove it from the source folder
switch addResult { do {
// now that we have added the feed, remove it from the other collection try await removeFeed(for: account, with: feed, from: sourceFolder)
case .success: } catch {
self?.removeFeed(for: account, with: feed, from: from) { removeResult in sourceFolder.addFeed(feed)
switch removeResult { throw FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed.nameForDisplay, sourceFolder.nameForDisplay, destinationFolder.nameForDisplay)
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))
}
} }
// 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 { func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws {

View File

@ -153,25 +153,11 @@ protocol FeedlyAPICallerDelegate: AnyObject {
func importOPML(_ opmlData: Data) async throws { func importOPML(_ opmlData: Data) async throws {
guard !isSuspended else { guard !isSuspended else { throw TransportError.suspended }
throw TransportError.suspended
}
guard let accessToken = credentials?.secret else {
throw CredentialsError.incompleteCredentials
}
var components = baseURLComponents var request = try urlRequest(path: "/v3/opml", method: HTTPMethod.post, addJSONHeaders: false, addOauthToken: true)
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"
request.addValue("text/xml", forHTTPHeaderField: HTTPRequestHeader.contentType) request.addValue("text/xml", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.acceptType)
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
request.httpBody = opmlData request.httpBody = opmlData
let (httpResponse, _) = try await send(request: request, resultType: String.self) 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 { func createCollection(named label: String) async throws -> FeedlyCollection {
guard !isSuspended else { guard !isSuspended else { throw TransportError.suspended }
throw TransportError.suspended
}
guard let accessToken = credentials?.secret else {
throw CredentialsError.incompleteCredentials
}
var components = baseURLComponents var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, addJSONHeaders: true, addOauthToken: true)
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)
struct CreateCollectionBody: Encodable { struct CreateCollectionBody: Encodable {
var label: String var label: String
@ -216,166 +186,81 @@ protocol FeedlyAPICallerDelegate: AnyObject {
} }
return collection return collection
} }
func renameCollection(with id: String, to name: String, completion: @escaping (Result<FeedlyCollection, Error>) -> ()) { func renameCollection(with id: String, to name: String) async throws -> FeedlyCollection {
guard !isSuspended else {
return DispatchQueue.main.async { guard !isSuspended else { throw TransportError.suspended }
completion(.failure(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
} }
let encoder = JSONEncoder()
guard let accessToken = credentials?.secret else { let data = try encoder.encode(RenameCollectionBody(id: id, label: name))
return DispatchQueue.main.async { request.httpBody = data
completion(.failure(CredentialsError.incompleteCredentials))
} let (httpResponse, collections) = try await send(request: request, resultType: [FeedlyCollection].self)
}
var components = baseURLComponents guard let collection = collections?.first, httpResponse.statusCode == HTTPResponseCode.OK else {
components.path = "/v3/collections" throw URLError(.cannotDecodeContentData)
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))
}
} }
return collection
} }
private func encodeForURLPath(_ pathComponent: String) -> String? { private func encodeForURLPath(_ pathComponent: String) -> String? {
return pathComponent.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed) return pathComponent.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed)
} }
func deleteCollection(with id: String, completion: @escaping (Result<Void, Error>) -> ()) { func deleteCollection(with id: String) async throws {
guard !isSuspended else {
return DispatchQueue.main.async { guard !isSuspended else { throw TransportError.suspended }
completion(.failure(TransportError.suspended))
}
}
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completion(.failure(CredentialsError.incompleteCredentials))
}
}
guard let encodedID = encodeForURLPath(id) else { guard let encodedID = encodeForURLPath(id) else {
return DispatchQueue.main.async { throw FeedlyAccountDelegateError.unexpectedResourceID(id)
completion(.failure(FeedlyAccountDelegateError.unexpectedResourceID(id)))
}
} }
var components = baseURLComponents let request = try urlRequest(path: "/v3/collections/\(encodedID)", method: HTTPMethod.delete, addJSONHeaders: true, addOauthToken: true)
components.percentEncodedPath = "/v3/collections/\(encodedID)"
let (httpResponse, _) = try await send(request: request, resultType: Optional<FeedlyCollection>.self)
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.") guard httpResponse.statusCode == HTTPResponseCode.OK else {
} throw URLError(.cannotDecodeContentData)
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<FeedlyCollection>.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))
}
} }
} }
func removeFeed(_ feedId: String, fromCollectionWith collectionID: String, completion: @escaping (Result<Void, Error>) -> ()) { func removeFeed(_ feedId: String, fromCollectionWith collectionID: String) async throws {
guard !isSuspended else {
return DispatchQueue.main.async { guard !isSuspended else { throw TransportError.suspended }
completion(.failure(TransportError.suspended))
}
}
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completion(.failure(CredentialsError.incompleteCredentials))
}
}
guard let encodedCollectionID = encodeForURLPath(collectionID) else { guard let encodedCollectionID = encodeForURLPath(collectionID) else {
return DispatchQueue.main.async { throw FeedlyAccountDelegateError.unexpectedResourceID(collectionID)
completion(.failure(FeedlyAccountDelegateError.unexpectedResourceID(collectionID)))
}
} }
var components = baseURLComponents var components = baseURLComponents
components.percentEncodedPath = "/v3/collections/\(encodedCollectionID)/feeds/.mdelete" components.percentEncodedPath = "/v3/collections/\(encodedCollectionID)/feeds/.mdelete"
guard let url = components.url else { guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.") fatalError("\(components) does not produce a valid URL.")
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "DELETE" request.httpMethod = HTTPMethod.delete
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) _addJSONHeaders(&request)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type") try addOauthAccessToken(&request)
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
struct RemovableFeed: Encodable {
do { let id: String
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))
}
} }
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`. // `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 // 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 let (httpResponse, _) = try await send(request: request, resultType: Optional<[FeedlyFeed]>.self)
switch result {
case .success((let httpResponse, _)): guard httpResponse.statusCode == HTTPResponseCode.OK else {
if httpResponse.statusCode == 200 { throw URLError(.cannotDecodeContentData)
completion(.success(()))
} else {
completion(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completion(.failure(error))
}
} }
} }
} }
@ -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
}
}