From be4564716f7a589de37a6d588b0f28e0138b1a4e Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Wed, 8 May 2024 22:44:19 -0700 Subject: [PATCH] Convert NewsBlurAPICaller to async await. --- .../NewsBlur/NewsBlurAPICaller+Internal.swift | 197 +++-------- .../Sources/NewsBlur/NewsBlurAPICaller.swift | 317 +++++++----------- 2 files changed, 157 insertions(+), 357 deletions(-) diff --git a/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller+Internal.swift b/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller+Internal.swift index f54ba8369..f6fc4e996 100644 --- a/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller+Internal.swift +++ b/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller+Internal.swift @@ -35,194 +35,85 @@ public enum NewsBlurError: LocalizedError, Sendable { // MARK: - Interact with endpoints extension NewsBlurAPICaller { - // GET endpoint, discard response - func requestData( - endpoint: String, - completion: @escaping (Result) -> Void - ) { - let callURL = baseURL.appendingPathComponent(endpoint) - requestData(callURL: callURL, completion: completion) + /// GET endpoint, discard response + func requestData(endpoint: String) async throws { + + let callURL = baseURL.appendingPathComponent(endpoint) + try await requestData(callURL: callURL) } - // GET endpoint - func requestData( - endpoint: String, - resultType: R.Type, - dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, - keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, - completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void - ) { - let callURL = baseURL.appendingPathComponent(endpoint) + /// GET endpoint + func requestData(endpoint: String, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) { - requestData( - callURL: callURL, - resultType: resultType, - dateDecoding: dateDecoding, - keyDecoding: keyDecoding, - completion: completion - ) + let callURL = baseURL.appendingPathComponent(endpoint) + return try await requestData(callURL: callURL, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) } - // POST to endpoint, discard response - func sendUpdates( - endpoint: String, - payload: NewsBlurDataConvertible, - completion: @escaping (Result) -> Void - ) { - let callURL = baseURL.appendingPathComponent(endpoint) + /// POST to endpoint, discard response + func sendUpdates(endpoint: String, payload: NewsBlurDataConvertible) async throws { - sendUpdates(callURL: callURL, payload: payload, completion: completion) + let callURL = baseURL.appendingPathComponent(endpoint) + try await sendUpdates(callURL: callURL, payload: payload) } - // POST to endpoint - func sendUpdates( - endpoint: String, - payload: NewsBlurDataConvertible, - resultType: R.Type, - dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, - keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, - completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void - ) { - let callURL = baseURL.appendingPathComponent(endpoint) + /// POST to endpoint + func sendUpdates(endpoint: String, payload: NewsBlurDataConvertible, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) { - sendUpdates( - callURL: callURL, - payload: payload, - resultType: resultType, - dateDecoding: dateDecoding, - keyDecoding: keyDecoding, - completion: completion - ) + let callURL = baseURL.appendingPathComponent(endpoint) + return try await sendUpdates(callURL: callURL, payload: payload, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) } } // MARK: - Interact with URLs extension NewsBlurAPICaller { - // GET URL with params, discard response - func requestData( - callURL: URL?, - completion: @escaping (Result) -> Void - ) { - guard let callURL = callURL else { - completion(.failure(TransportError.noURL)) - return - } + + /// GET URL with params, discard response + func requestData(callURL: URL) async throws { + + guard !isSuspended else { throw TransportError.suspended } let request = URLRequest(url: callURL, newsBlurCredentials: credentials) - - Task { @MainActor in - - do { - try await transport.send(request: request) - completion(.success(())) - } catch { - completion(.failure(error)) - } - } + try await transport.send(request: request) } - // GET URL with params - func requestData( - callURL: URL?, - resultType: R.Type, - dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, - keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, - completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void - ) { - guard let callURL = callURL else { - completion(.failure(TransportError.noURL)) - return - } + /// GET URL with params + @discardableResult + func requestData(callURL: URL, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) { + + guard !isSuspended else { throw TransportError.suspended } let request = URLRequest(url: callURL, newsBlurCredentials: credentials) - - Task { @MainActor in - - do { - let response = try await transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) - - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - completion(.success(response)) - - } catch { - completion(.failure(error)) - } - } + let response = try await transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) + return response } - // POST to URL with params, discard response - func sendUpdates( - callURL: URL?, - payload: NewsBlurDataConvertible, - completion: @escaping (Result) -> Void - ) { - guard let callURL = callURL else { - completion(.failure(TransportError.noURL)) - return - } + /// POST to URL with params, discard response + func sendUpdates(callURL: URL, payload: NewsBlurDataConvertible) async throws { + + guard !isSuspended else { throw TransportError.suspended } var request = URLRequest(url: callURL, newsBlurCredentials: credentials) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) + request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType) request.httpBody = payload.asData - Task { @MainActor in - - do { - try await transport.send(request: request, method: HTTPMethod.post) - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - completion(.success(())) - } catch { - completion(.failure(error)) - } - } + try await transport.send(request: request, method: HTTPMethod.post) } - // POST to URL with params - func sendUpdates( - callURL: URL?, - payload: NewsBlurDataConvertible, - resultType: R.Type, - dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, - keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, - completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void - ) { - guard let callURL = callURL else { - completion(.failure(TransportError.noURL)) - return - } + /// POST to URL with params + func sendUpdates(callURL: URL, payload: NewsBlurDataConvertible, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) { + + guard !isSuspended else { throw TransportError.suspended } guard let data = payload.asData else { - completion(.failure(NewsBlurError.invalidParameter)) - return + throw NewsBlurError.invalidParameter } var request = URLRequest(url: callURL, newsBlurCredentials: credentials) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) + request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType) - Task { @MainActor in - - do { - - let response = try await transport.send(request: request, method: HTTPMethod.post, data: data, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) - - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - - completion(.success(response)) - - } catch { - completion(.failure(error)) - } - } + let response = try await transport.send(request: request, method: HTTPMethod.post, data: data, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) + return response } } diff --git a/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller.swift b/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller.swift index 37de7f991..6054859a3 100644 --- a/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller.swift +++ b/NewsBlur/Sources/NewsBlur/NewsBlurAPICaller.swift @@ -16,7 +16,7 @@ import Secrets let baseURL = URL(string: "https://www.newsblur.com/")! var transport: Transport! - var suspended = false + var isSuspended = false public var credentials: Credentials? @@ -28,253 +28,162 @@ import Secrets /// Cancels all pending requests rejects any that come in later public func suspend() { transport.cancelAll() - suspended = true + isSuspended = true } public func resume() { - suspended = false + isSuspended = false } - public func validateCredentials(completion: @escaping (Result) -> Void) { - requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self) { result in - switch result { - case .success((let response, let payload)): - guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else { - let error = payload?.errors?.username ?? payload?.errors?.others - if let message = error?.first { - completion(.failure(NewsBlurError.general(message: message))) - } else { - completion(.failure(NewsBlurError.unknown)) - } - return - } + public func validateCredentials() async throws -> Credentials? { - guard let username = self.credentials?.username else { - completion(.failure(NewsBlurError.unknown)) - return - } + let (response, payload) = try await requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self) - let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url) - for cookie in cookies where cookie.name == Self.sessionIDCookieKey { - let credentials = Credentials(type: .newsBlurSessionID, username: username, secret: cookie.value) - completion(.success(credentials)) - return - } - - completion(.failure(NewsBlurError.general(message: "Failed to retrieve session"))) - case .failure(let error): - completion(.failure(error)) + guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else { + let error = payload?.errors?.username ?? payload?.errors?.others + if let message = error?.first { + throw NewsBlurError.general(message: message) } + throw NewsBlurError.unknown } - } - public func logout(completion: @escaping (Result) -> Void) { - requestData(endpoint: "api/logout", completion: completion) - } - - public func retrieveFeeds(completion: @escaping (Result<([NewsBlurFeed]?, [NewsBlurFolder]?), Error>) -> Void) { - let url = baseURL - .appendingPathComponent("reader/feeds") - .appendingQueryItems([ - URLQueryItem(name: "flat", value: "true"), - URLQueryItem(name: "update_counts", value: "false"), - ]) - - requestData(callURL: url, resultType: NewsBlurFeedsResponse.self) { result in - switch result { - case .success((_, let payload)): - completion(.success((payload?.feeds, payload?.folders))) - case .failure(let error): - completion(.failure(error)) - } + guard let username = self.credentials?.username else { + throw NewsBlurError.unknown } - } - func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { - let url = baseURL - .appendingPathComponent(endpoint) - .appendingQueryItems([ - URLQueryItem(name: "include_timestamps", value: "true"), - ]) - - requestData( - callURL: url, - resultType: NewsBlurStoryHashesResponse.self, - dateDecoding: .secondsSince1970 - ) { result in - switch result { - case .success((_, let payload)): - let hashes = payload?.unread ?? payload?.starred - completion(.success(hashes)) - case .failure(let error): - completion(.failure(error)) - } + let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url) + for cookie in cookies where cookie.name == Self.sessionIDCookieKey { + let credentials = Credentials(type: .newsBlurSessionID, username: username, secret: cookie.value) + return credentials } + + throw NewsBlurError.general(message: "Failed to retrieve session") } - public func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { - retrieveStoryHashes( - endpoint: "reader/unread_story_hashes", - completion: completion - ) + public func logout() async throws { + + try await requestData(endpoint: "api/logout") } - public func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { - retrieveStoryHashes( - endpoint: "reader/starred_story_hashes", - completion: completion - ) + public func retrieveFeeds() async throws -> ([NewsBlurFeed]?, [NewsBlurFolder]?) { + + let url: URL! = baseURL + .appendingPathComponent("reader/feeds") + .appendingQueryItems([ + URLQueryItem(name: "flat", value: "true"), + URLQueryItem(name: "update_counts", value: "false"), + ]) + + let (_, payload) = try await requestData(callURL: url, resultType: NewsBlurFeedsResponse.self) + return (payload?.feeds, payload?.folders) } - public func retrieveStories(feedID: String, page: Int, completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) { - let url = baseURL - .appendingPathComponent("reader/feed/\(feedID)") - .appendingQueryItems([ - URLQueryItem(name: "page", value: String(page)), - URLQueryItem(name: "order", value: "newest"), - URLQueryItem(name: "read_filter", value: "all"), - URLQueryItem(name: "include_hidden", value: "false"), - URLQueryItem(name: "include_story_content", value: "true"), - ]) + func retrieveStoryHashes(endpoint: String) async throws -> [NewsBlurStoryHash]? { - requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in - switch result { - case .success(let (response, payload)): - completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date))) - case .failure(let error): - completion(.failure(error)) - } - } + let url: URL! = baseURL + .appendingPathComponent(endpoint) + .appendingQueryItems([ + URLQueryItem(name: "include_timestamps", value: "true"), + ]) + + let (_, payload) = try await requestData(callURL: url, resultType: NewsBlurStoryHashesResponse.self, dateDecoding: .secondsSince1970) + + let hashes = payload?.unread ?? payload?.starred + return hashes } - public func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) { - let url = baseURL - .appendingPathComponent("reader/river_stories") - .appendingQueryItem(.init(name: "include_hidden", value: "false"))? - .appendingQueryItems(hashes.map { - URLQueryItem(name: "h", value: $0.hash) - }) + public func retrieveUnreadStoryHashes() async throws -> [NewsBlurStoryHash]? { - requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in - switch result { - case .success(let (response, payload)): - completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date))) - case .failure(let error): - completion(.failure(error)) - } - } + return try await retrieveStoryHashes(endpoint: "reader/unread_story_hashes") } - public func markAsUnread(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/mark_story_hash_as_unread", - payload: NewsBlurStoryStatusChange(hashes: hashes), - completion: completion - ) + public func retrieveStarredStoryHashes() async throws -> [NewsBlurStoryHash]? { + + return try await retrieveStoryHashes(endpoint: "reader/starred_story_hashes") } - public func markAsRead(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/mark_story_hashes_as_read", - payload: NewsBlurStoryStatusChange(hashes: hashes), - completion: completion - ) + public func retrieveStories(feedID: String, page: Int) async throws -> ([NewsBlurStory]?, Date?) { + + let url: URL! = baseURL + .appendingPathComponent("reader/feed/\(feedID)") + .appendingQueryItems([ + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "order", value: "newest"), + URLQueryItem(name: "read_filter", value: "all"), + URLQueryItem(name: "include_hidden", value: "false"), + URLQueryItem(name: "include_story_content", value: "true"), + ]) + + let (response, payload) = try await requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) + return (payload?.stories, HTTPDateInfo(urlResponse: response)?.date) } - public func star(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/mark_story_hash_as_starred", - payload: NewsBlurStoryStatusChange(hashes: hashes), - completion: completion - ) + public func retrieveStories(hashes: [NewsBlurStoryHash]) async throws -> ([NewsBlurStory]?, Date?) { + + let url: URL! = baseURL + .appendingPathComponent("reader/river_stories") + .appendingQueryItem(.init(name: "include_hidden", value: "false"))? + .appendingQueryItems(hashes.map { + URLQueryItem(name: "h", value: $0.hash) + }) + + let (response, payload) = try await requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) + return (payload?.stories, HTTPDateInfo(urlResponse: response)?.date) } - public func unstar(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/mark_story_hash_as_unstarred", - payload: NewsBlurStoryStatusChange(hashes: hashes), - completion: completion - ) + public func markAsUnread(hashes: [String]) async throws { + + try await sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes)) } - public func addFolder(named name: String, completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/add_folder", - payload: NewsBlurFolderChange.add(name), - completion: completion - ) + public func markAsRead(hashes: [String]) async throws { + + try await sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes)) } - public func renameFolder(with folder: String, to name: String, completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/rename_folder", - payload: NewsBlurFolderChange.rename(folder, name), - completion: completion - ) + public func star(hashes: [String]) async throws { + + try await sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes)) } - public func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/delete_folder", - payload: NewsBlurFolderChange.delete(name, feedIDs), - completion: completion - ) + public func unstar(hashes: [String]) async throws { + + try await sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes)) } - public func addURL(_ url: String, folder: String?, completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/add_url", - payload: NewsBlurFeedChange.add(url, folder), - resultType: NewsBlurAddURLResponse.self - ) { result in - switch result { - case .success((_, let payload)): - completion(.success(payload?.feed)) - case .failure(let error): - completion(.failure(error)) - } - } + public func addFolder(named name: String) async throws { + + try await sendUpdates(endpoint: "reader/add_folder", payload: NewsBlurFolderChange.add(name)) } - public func renameFeed(feedID: String, newName: String, completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/rename_feed", - payload: NewsBlurFeedChange.rename(feedID, newName) - ) { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + public func renameFolder(with folder: String, to name: String) async throws { + + try await sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name)) } - public func deleteFeed(feedID: String, folder: String? = nil, completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/delete_feed", - payload: NewsBlurFeedChange.delete(feedID, folder) - ) { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + public func removeFolder(named name: String, feedIDs: [String]) async throws { + + try await sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name, feedIDs)) } - public func moveFeed(feedID: String, from: String?, to: String?, completion: @escaping (Result) -> Void) { - sendUpdates( - endpoint: "reader/move_feed_to_folder", - payload: NewsBlurFeedChange.move(feedID, from, to) - ) { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + public func addURL(_ url: String, folder: String?) async throws -> NewsBlurFeed? { + + let (_, payload) = try await sendUpdates(endpoint: "reader/add_url", payload: NewsBlurFeedChange.add(url, folder), resultType: NewsBlurAddURLResponse.self) + return payload?.feed + } + + public func renameFeed(feedID: String, newName: String) async throws { + + try await sendUpdates(endpoint: "reader/rename_feed", payload: NewsBlurFeedChange.rename(feedID, newName)) + } + + public func deleteFeed(feedID: String, folder: String? = nil) async throws { + + try await sendUpdates(endpoint: "reader/delete_feed", payload: NewsBlurFeedChange.delete(feedID, folder)) + } + + public func moveFeed(feedID: String, from: String?, to: String?) async throws { + + try await sendUpdates(endpoint: "reader/move_feed_to_folder", payload: NewsBlurFeedChange.move(feedID, from, to)) } }