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
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
func requestData<R: Decodable & Sendable>(
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<R: Decodable & Sendable>(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, Error>) -> 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<R: Decodable & Sendable>(
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<R: Decodable & Sendable>(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, Error>) -> 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<R: Decodable & Sendable>(
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<R: Decodable & Sendable>(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, Error>) -> 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<R: Decodable & Sendable>(
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<R: Decodable & Sendable>(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
}
}

View File

@ -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<Credentials?, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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<NewsBlurFeed?, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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))
}
}