Convert NewsBlurAPICaller to async await.

This commit is contained in:
Brent Simmons 2024-05-08 22:44:19 -07:00
parent a3151181eb
commit be4564716f
2 changed files with 157 additions and 357 deletions

View File

@ -35,194 +35,85 @@ public enum NewsBlurError: LocalizedError, Sendable {
// MARK: - Interact with endpoints // MARK: - Interact with endpoints
extension NewsBlurAPICaller { extension NewsBlurAPICaller {
// GET endpoint, discard response
func requestData(
endpoint: String,
completion: @escaping (Result<Void, Error>) -> 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 /// GET endpoint
func requestData<R: Decodable & Sendable>( func requestData<R: Decodable & Sendable>(endpoint: String, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
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)
requestData( let callURL = baseURL.appendingPathComponent(endpoint)
callURL: callURL, return try await requestData(callURL: callURL, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
resultType: resultType,
dateDecoding: dateDecoding,
keyDecoding: keyDecoding,
completion: completion
)
} }
// POST to endpoint, discard response /// POST to endpoint, discard response
func sendUpdates( func sendUpdates(endpoint: String, payload: NewsBlurDataConvertible) async throws {
endpoint: String,
payload: NewsBlurDataConvertible,
completion: @escaping (Result<Void, Error>) -> Void
) {
let callURL = baseURL.appendingPathComponent(endpoint)
sendUpdates(callURL: callURL, payload: payload, completion: completion) let callURL = baseURL.appendingPathComponent(endpoint)
try await sendUpdates(callURL: callURL, payload: payload)
} }
// POST to endpoint /// POST to endpoint
func sendUpdates<R: Decodable & Sendable>( func sendUpdates<R: Decodable & Sendable>(endpoint: String, payload: NewsBlurDataConvertible, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
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)
sendUpdates( let callURL = baseURL.appendingPathComponent(endpoint)
callURL: callURL, return try await sendUpdates(callURL: callURL, payload: payload, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
payload: payload,
resultType: resultType,
dateDecoding: dateDecoding,
keyDecoding: keyDecoding,
completion: completion
)
} }
} }
// MARK: - Interact with URLs // MARK: - Interact with URLs
extension NewsBlurAPICaller { extension NewsBlurAPICaller {
// GET URL with params, discard response
func requestData( /// GET URL with params, discard response
callURL: URL?, func requestData(callURL: URL) async throws {
completion: @escaping (Result<Void, Error>) -> Void
) { guard !isSuspended else { throw TransportError.suspended }
guard let callURL = callURL else {
completion(.failure(TransportError.noURL))
return
}
let request = URLRequest(url: callURL, newsBlurCredentials: credentials) let request = URLRequest(url: callURL, newsBlurCredentials: credentials)
Task { @MainActor in
do {
try await transport.send(request: request) try await transport.send(request: request)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
} }
// GET URL with params /// GET URL with params
func requestData<R: Decodable & Sendable>( @discardableResult
callURL: URL?, func requestData<R: Decodable & Sendable>(callURL: URL, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
resultType: R.Type,
dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, guard !isSuspended else { throw TransportError.suspended }
keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys,
completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void
) {
guard let callURL = callURL else {
completion(.failure(TransportError.noURL))
return
}
let request = URLRequest(url: callURL, newsBlurCredentials: credentials) 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) let response = try await transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
return response
if self.suspended {
completion(.failure(TransportError.suspended))
return
}
completion(.success(response))
} catch {
completion(.failure(error))
}
}
} }
// POST to URL with params, discard response /// POST to URL with params, discard response
func sendUpdates( func sendUpdates(callURL: URL, payload: NewsBlurDataConvertible) async throws {
callURL: URL?,
payload: NewsBlurDataConvertible, guard !isSuspended else { throw TransportError.suspended }
completion: @escaping (Result<Void, Error>) -> Void
) {
guard let callURL = callURL else {
completion(.failure(TransportError.noURL))
return
}
var request = URLRequest(url: callURL, newsBlurCredentials: credentials) 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 request.httpBody = payload.asData
Task { @MainActor in
do {
try await transport.send(request: request, method: HTTPMethod.post) try await transport.send(request: request, method: HTTPMethod.post)
if self.suspended {
completion(.failure(TransportError.suspended))
return
}
completion(.success(()))
} catch {
completion(.failure(error))
}
}
} }
// POST to URL with params /// POST to URL with params
func sendUpdates<R: Decodable & Sendable>( func sendUpdates<R: Decodable & Sendable>(callURL: URL, payload: NewsBlurDataConvertible, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
callURL: URL?,
payload: NewsBlurDataConvertible, guard !isSuspended else { throw TransportError.suspended }
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
}
guard let data = payload.asData else { guard let data = payload.asData else {
completion(.failure(NewsBlurError.invalidParameter)) throw NewsBlurError.invalidParameter
return
} }
var request = URLRequest(url: callURL, newsBlurCredentials: credentials) 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) let response = try await transport.send(request: request, method: HTTPMethod.post, data: data, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
return response
if self.suspended {
completion(.failure(TransportError.suspended))
return
}
completion(.success(response))
} catch {
completion(.failure(error))
}
}
} }
} }

View File

@ -16,7 +16,7 @@ import Secrets
let baseURL = URL(string: "https://www.newsblur.com/")! let baseURL = URL(string: "https://www.newsblur.com/")!
var transport: Transport! var transport: Transport!
var suspended = false var isSuspended = false
public var credentials: Credentials? public var credentials: Credentials?
@ -28,106 +28,83 @@ import Secrets
/// Cancels all pending requests rejects any that come in later /// Cancels all pending requests rejects any that come in later
public func suspend() { public func suspend() {
transport.cancelAll() transport.cancelAll()
suspended = true isSuspended = true
} }
public func resume() { public func resume() {
suspended = false isSuspended = false
} }
public func validateCredentials(completion: @escaping (Result<Credentials?, Error>) -> Void) { public func validateCredentials() async throws -> Credentials? {
requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self) { result in
switch result { let (response, payload) = try await requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self)
case .success((let response, let payload)):
guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else { guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else {
let error = payload?.errors?.username ?? payload?.errors?.others let error = payload?.errors?.username ?? payload?.errors?.others
if let message = error?.first { if let message = error?.first {
completion(.failure(NewsBlurError.general(message: message))) throw NewsBlurError.general(message: message)
} else {
completion(.failure(NewsBlurError.unknown))
} }
return throw NewsBlurError.unknown
} }
guard let username = self.credentials?.username else { guard let username = self.credentials?.username else {
completion(.failure(NewsBlurError.unknown)) throw NewsBlurError.unknown
return
} }
let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url) let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url)
for cookie in cookies where cookie.name == Self.sessionIDCookieKey { for cookie in cookies where cookie.name == Self.sessionIDCookieKey {
let credentials = Credentials(type: .newsBlurSessionID, username: username, secret: cookie.value) let credentials = Credentials(type: .newsBlurSessionID, username: username, secret: cookie.value)
completion(.success(credentials)) return credentials
return
} }
completion(.failure(NewsBlurError.general(message: "Failed to retrieve session"))) throw NewsBlurError.general(message: "Failed to retrieve session")
case .failure(let error):
completion(.failure(error))
}
}
} }
public func logout(completion: @escaping (Result<Void, Error>) -> Void) { public func logout() async throws {
requestData(endpoint: "api/logout", completion: completion)
try await requestData(endpoint: "api/logout")
} }
public func retrieveFeeds(completion: @escaping (Result<([NewsBlurFeed]?, [NewsBlurFolder]?), Error>) -> Void) { public func retrieveFeeds() async throws -> ([NewsBlurFeed]?, [NewsBlurFolder]?) {
let url = baseURL
let url: URL! = baseURL
.appendingPathComponent("reader/feeds") .appendingPathComponent("reader/feeds")
.appendingQueryItems([ .appendingQueryItems([
URLQueryItem(name: "flat", value: "true"), URLQueryItem(name: "flat", value: "true"),
URLQueryItem(name: "update_counts", value: "false"), URLQueryItem(name: "update_counts", value: "false"),
]) ])
requestData(callURL: url, resultType: NewsBlurFeedsResponse.self) { result in let (_, payload) = try await requestData(callURL: url, resultType: NewsBlurFeedsResponse.self)
switch result { return (payload?.feeds, payload?.folders)
case .success((_, let payload)):
completion(.success((payload?.feeds, payload?.folders)))
case .failure(let error):
completion(.failure(error))
}
}
} }
func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { func retrieveStoryHashes(endpoint: String) async throws -> [NewsBlurStoryHash]? {
let url = baseURL
let url: URL! = baseURL
.appendingPathComponent(endpoint) .appendingPathComponent(endpoint)
.appendingQueryItems([ .appendingQueryItems([
URLQueryItem(name: "include_timestamps", value: "true"), URLQueryItem(name: "include_timestamps", value: "true"),
]) ])
requestData( let (_, payload) = try await requestData(callURL: url, resultType: NewsBlurStoryHashesResponse.self, dateDecoding: .secondsSince1970)
callURL: url,
resultType: NewsBlurStoryHashesResponse.self,
dateDecoding: .secondsSince1970
) { result in
switch result {
case .success((_, let payload)):
let hashes = payload?.unread ?? payload?.starred let hashes = payload?.unread ?? payload?.starred
completion(.success(hashes)) return hashes
case .failure(let error):
completion(.failure(error))
}
}
} }
public func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { public func retrieveUnreadStoryHashes() async throws -> [NewsBlurStoryHash]? {
retrieveStoryHashes(
endpoint: "reader/unread_story_hashes", return try await retrieveStoryHashes(endpoint: "reader/unread_story_hashes")
completion: completion
)
} }
public func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { public func retrieveStarredStoryHashes() async throws -> [NewsBlurStoryHash]? {
retrieveStoryHashes(
endpoint: "reader/starred_story_hashes", return try await retrieveStoryHashes(endpoint: "reader/starred_story_hashes")
completion: completion
)
} }
public func retrieveStories(feedID: String, page: Int, completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) { public func retrieveStories(feedID: String, page: Int) async throws -> ([NewsBlurStory]?, Date?) {
let url = baseURL
let url: URL! = baseURL
.appendingPathComponent("reader/feed/\(feedID)") .appendingPathComponent("reader/feed/\(feedID)")
.appendingQueryItems([ .appendingQueryItems([
URLQueryItem(name: "page", value: String(page)), URLQueryItem(name: "page", value: String(page)),
@ -137,144 +114,76 @@ import Secrets
URLQueryItem(name: "include_story_content", value: "true"), URLQueryItem(name: "include_story_content", value: "true"),
]) ])
requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in let (response, payload) = try await requestData(callURL: url, resultType: NewsBlurStoriesResponse.self)
switch result { return (payload?.stories, HTTPDateInfo(urlResponse: response)?.date)
case .success(let (response, payload)):
completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date)))
case .failure(let error):
completion(.failure(error))
}
}
} }
public func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) { public func retrieveStories(hashes: [NewsBlurStoryHash]) async throws -> ([NewsBlurStory]?, Date?) {
let url = baseURL
let url: URL! = baseURL
.appendingPathComponent("reader/river_stories") .appendingPathComponent("reader/river_stories")
.appendingQueryItem(.init(name: "include_hidden", value: "false"))? .appendingQueryItem(.init(name: "include_hidden", value: "false"))?
.appendingQueryItems(hashes.map { .appendingQueryItems(hashes.map {
URLQueryItem(name: "h", value: $0.hash) URLQueryItem(name: "h", value: $0.hash)
}) })
requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in let (response, payload) = try await requestData(callURL: url, resultType: NewsBlurStoriesResponse.self)
switch result { return (payload?.stories, HTTPDateInfo(urlResponse: response)?.date)
case .success(let (response, payload)):
completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date)))
case .failure(let error):
completion(.failure(error))
}
}
} }
public func markAsUnread(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) { public func markAsUnread(hashes: [String]) async throws {
sendUpdates(
endpoint: "reader/mark_story_hash_as_unread", try await sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes))
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
} }
public func markAsRead(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) { public func markAsRead(hashes: [String]) async throws {
sendUpdates(
endpoint: "reader/mark_story_hashes_as_read", try await sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes))
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
} }
public func star(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) { public func star(hashes: [String]) async throws {
sendUpdates(
endpoint: "reader/mark_story_hash_as_starred", try await sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes))
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
} }
public func unstar(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) { public func unstar(hashes: [String]) async throws {
sendUpdates(
endpoint: "reader/mark_story_hash_as_unstarred", try await sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes))
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
} }
public func addFolder(named name: String, completion: @escaping (Result<Void, Error>) -> Void) { public func addFolder(named name: String) async throws {
sendUpdates(
endpoint: "reader/add_folder", try await sendUpdates(endpoint: "reader/add_folder", payload: NewsBlurFolderChange.add(name))
payload: NewsBlurFolderChange.add(name),
completion: completion
)
} }
public func renameFolder(with folder: String, to name: String, completion: @escaping (Result<Void, Error>) -> Void) { public func renameFolder(with folder: String, to name: String) async throws {
sendUpdates(
endpoint: "reader/rename_folder", try await sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name))
payload: NewsBlurFolderChange.rename(folder, name),
completion: completion
)
} }
public func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result<Void, Error>) -> Void) { public func removeFolder(named name: String, feedIDs: [String]) async throws {
sendUpdates(
endpoint: "reader/delete_folder", try await sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name, feedIDs))
payload: NewsBlurFolderChange.delete(name, feedIDs),
completion: completion
)
} }
public func addURL(_ url: String, folder: String?, completion: @escaping (Result<NewsBlurFeed?, Error>) -> Void) { public func addURL(_ url: String, folder: String?) async throws -> NewsBlurFeed? {
sendUpdates(
endpoint: "reader/add_url", let (_, payload) = try await sendUpdates(endpoint: "reader/add_url", payload: NewsBlurFeedChange.add(url, folder), resultType: NewsBlurAddURLResponse.self)
payload: NewsBlurFeedChange.add(url, folder), return payload?.feed
resultType: NewsBlurAddURLResponse.self
) { result in
switch result {
case .success((_, let payload)):
completion(.success(payload?.feed))
case .failure(let error):
completion(.failure(error))
}
}
} }
public func renameFeed(feedID: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) { public func renameFeed(feedID: String, newName: String) async throws {
sendUpdates(
endpoint: "reader/rename_feed", try await sendUpdates(endpoint: "reader/rename_feed", payload: NewsBlurFeedChange.rename(feedID, newName))
payload: NewsBlurFeedChange.rename(feedID, newName)
) { result in
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
} }
public func deleteFeed(feedID: String, folder: String? = nil, completion: @escaping (Result<Void, Error>) -> Void) { public func deleteFeed(feedID: String, folder: String? = nil) async throws {
sendUpdates(
endpoint: "reader/delete_feed", try await sendUpdates(endpoint: "reader/delete_feed", payload: NewsBlurFeedChange.delete(feedID, folder))
payload: NewsBlurFeedChange.delete(feedID, folder)
) { result in
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
} }
public func moveFeed(feedID: String, from: String?, to: String?, completion: @escaping (Result<Void, Error>) -> Void) { public func moveFeed(feedID: String, from: String?, to: String?) async throws {
sendUpdates(
endpoint: "reader/move_feed_to_folder", try await sendUpdates(endpoint: "reader/move_feed_to_folder", payload: NewsBlurFeedChange.move(feedID, from, to))
payload: NewsBlurFeedChange.move(feedID, from, to)
) { result in
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
} }
} }