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)
try await transport.send(request: request)
Task { @MainActor in
do {
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)
let response = try await transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
Task { @MainActor in return response
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))
}
}
} }
// 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 try await transport.send(request: request, method: HTTPMethod.post)
do {
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 let response = try await transport.send(request: request, method: HTTPMethod.post, data: data, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
return response
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))
}
}
} }
} }

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,253 +28,162 @@ 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 {
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
}
guard let username = self.credentials?.username else { let (response, payload) = try await requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self)
completion(.failure(NewsBlurError.unknown))
return
}
let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url) guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else {
for cookie in cookies where cookie.name == Self.sessionIDCookieKey { let error = payload?.errors?.username ?? payload?.errors?.others
let credentials = Credentials(type: .newsBlurSessionID, username: username, secret: cookie.value) if let message = error?.first {
completion(.success(credentials)) throw NewsBlurError.general(message: message)
return
}
completion(.failure(NewsBlurError.general(message: "Failed to retrieve session")))
case .failure(let error):
completion(.failure(error))
} }
throw NewsBlurError.unknown
} }
}
public func logout(completion: @escaping (Result<Void, Error>) -> Void) { guard let username = self.credentials?.username else {
requestData(endpoint: "api/logout", completion: completion) throw NewsBlurError.unknown
}
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))
}
} }
}
func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url)
let url = baseURL for cookie in cookies where cookie.name == Self.sessionIDCookieKey {
.appendingPathComponent(endpoint) let credentials = Credentials(type: .newsBlurSessionID, username: username, secret: cookie.value)
.appendingQueryItems([ return credentials
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))
}
} }
throw NewsBlurError.general(message: "Failed to retrieve session")
} }
public func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { public func logout() async throws {
retrieveStoryHashes(
endpoint: "reader/unread_story_hashes", try await requestData(endpoint: "api/logout")
completion: completion
)
} }
public func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { public func retrieveFeeds() async throws -> ([NewsBlurFeed]?, [NewsBlurFolder]?) {
retrieveStoryHashes(
endpoint: "reader/starred_story_hashes", let url: URL! = baseURL
completion: completion .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) { func retrieveStoryHashes(endpoint: String) async throws -> [NewsBlurStoryHash]? {
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"),
])
requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in let url: URL! = baseURL
switch result { .appendingPathComponent(endpoint)
case .success(let (response, payload)): .appendingQueryItems([
completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date))) URLQueryItem(name: "include_timestamps", value: "true"),
case .failure(let error): ])
completion(.failure(error))
} 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) { public func retrieveUnreadStoryHashes() async throws -> [NewsBlurStoryHash]? {
let url = baseURL
.appendingPathComponent("reader/river_stories")
.appendingQueryItem(.init(name: "include_hidden", value: "false"))?
.appendingQueryItems(hashes.map {
URLQueryItem(name: "h", value: $0.hash)
})
requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in return try await retrieveStoryHashes(endpoint: "reader/unread_story_hashes")
switch result {
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 retrieveStarredStoryHashes() async throws -> [NewsBlurStoryHash]? {
sendUpdates(
endpoint: "reader/mark_story_hash_as_unread", return try await retrieveStoryHashes(endpoint: "reader/starred_story_hashes")
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
} }
public func markAsRead(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) { public func retrieveStories(feedID: String, page: Int) async throws -> ([NewsBlurStory]?, Date?) {
sendUpdates(
endpoint: "reader/mark_story_hashes_as_read", let url: URL! = baseURL
payload: NewsBlurStoryStatusChange(hashes: hashes), .appendingPathComponent("reader/feed/\(feedID)")
completion: completion .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, Error>) -> Void) { public func retrieveStories(hashes: [NewsBlurStoryHash]) async throws -> ([NewsBlurStory]?, Date?) {
sendUpdates(
endpoint: "reader/mark_story_hash_as_starred", let url: URL! = baseURL
payload: NewsBlurStoryStatusChange(hashes: hashes), .appendingPathComponent("reader/river_stories")
completion: completion .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, Error>) -> Void) { public func markAsUnread(hashes: [String]) async throws {
sendUpdates(
endpoint: "reader/mark_story_hash_as_unstarred", try await sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes))
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
} }
public func addFolder(named name: String, completion: @escaping (Result<Void, Error>) -> Void) { public func markAsRead(hashes: [String]) async throws {
sendUpdates(
endpoint: "reader/add_folder", try await sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes))
payload: NewsBlurFolderChange.add(name),
completion: completion
)
} }
public func renameFolder(with folder: String, to name: String, completion: @escaping (Result<Void, Error>) -> Void) { public func star(hashes: [String]) async throws {
sendUpdates(
endpoint: "reader/rename_folder", try await sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes))
payload: NewsBlurFolderChange.rename(folder, name),
completion: completion
)
} }
public func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result<Void, Error>) -> Void) { public func unstar(hashes: [String]) async throws {
sendUpdates(
endpoint: "reader/delete_folder", try await sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes))
payload: NewsBlurFolderChange.delete(name, feedIDs),
completion: completion
)
} }
public func addURL(_ url: String, folder: String?, completion: @escaping (Result<NewsBlurFeed?, Error>) -> Void) { public func addFolder(named name: String) async throws {
sendUpdates(
endpoint: "reader/add_url", try await sendUpdates(endpoint: "reader/add_folder", payload: NewsBlurFolderChange.add(name))
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 renameFeed(feedID: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) { public func renameFolder(with folder: String, to name: String) async throws {
sendUpdates(
endpoint: "reader/rename_feed", try await sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name))
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 removeFolder(named name: String, feedIDs: [String]) async throws {
sendUpdates(
endpoint: "reader/delete_feed", try await sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name, feedIDs))
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 addURL(_ url: String, folder: String?) async throws -> NewsBlurFeed? {
sendUpdates(
endpoint: "reader/move_feed_to_folder", let (_, payload) = try await sendUpdates(endpoint: "reader/add_url", payload: NewsBlurFeedChange.add(url, folder), resultType: NewsBlurAddURLResponse.self)
payload: NewsBlurFeedChange.move(feedID, from, to) return payload?.feed
) { result in }
switch result {
case .success: public func renameFeed(feedID: String, newName: String) async throws {
completion(.success(()))
case .failure(let error): try await sendUpdates(endpoint: "reader/rename_feed", payload: NewsBlurFeedChange.rename(feedID, newName))
completion(.failure(error)) }
}
} 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))
} }
} }