Convert methods to async await.

This commit is contained in:
Brent Simmons 2024-04-23 20:13:44 -07:00
parent 4041f024e6
commit a56ffa9405
9 changed files with 147 additions and 279 deletions

View File

@ -12,7 +12,7 @@ import Secrets
/// Models the access token response from Feedly.
/// https://developer.feedly.com/v3/auth/#exchanging-an-auth-code-for-a-refresh-token-and-an-access-token
public struct FeedlyOAuthAccessTokenResponse: Decodable, OAuthAccessTokenResponse {
public struct FeedlyOAuthAccessTokenResponse: Decodable, OAuthAccessTokenResponse, Sendable {
/// The ID of the Feedly user.
public var id: String
@ -45,55 +45,38 @@ extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
scope: oauthAuthorizationGrantScope,
client: client)
let caller = FeedlyAPICaller(transport: transport, api: environment, secretsProvider: secretsProvider)
let response = try await caller.requestAccessToken(request)
return try await withCheckedThrowingContinuation { continuation in
caller.requestAccessToken(request) { result in
switch result {
case .success(let response):
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
let refreshToken: Credentials? = {
guard let token = response.refreshToken else {
return nil
}
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
}()
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
continuation.resume(returning: grant)
case .failure(let error):
continuation.resume(throwing: error)
}
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
let refreshToken: Credentials? = {
guard let token = response.refreshToken else {
return nil
}
}
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
}()
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
return grant
}
}
extension FeedlyAccountDelegate: OAuthAccessTokenRefreshing {
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient) async throws -> OAuthAuthorizationGrant {
let request = OAuthRefreshAccessTokenRequest(refreshToken: refreshToken, scope: nil, client: client)
caller.refreshAccessToken(request) { result in
switch result {
case .success(let response):
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
let refreshToken: Credentials? = {
guard let token = response.refreshToken else {
return nil
}
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
}()
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
completion(.success(grant))
case .failure(let error):
completion(.failure(error))
let response = try await caller.refreshAccessToken(request)
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
let refreshToken: Credentials? = {
guard let token = response.refreshToken else {
return nil
}
}
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
}()
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
return grant
}
}

View File

@ -155,7 +155,7 @@ protocol FeedlyAPICallerDelegate: AnyObject {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/opml", method: HTTPMethod.post, includeJSONHeaders: false, includeOauthToken: true)
var request = try urlRequest(path: "/v3/opml", method: HTTPMethod.post, includeJSONHeaders: false, includeOAuthToken: true)
request.addValue("text/xml", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.acceptType)
request.httpBody = opmlData
@ -170,14 +170,12 @@ protocol FeedlyAPICallerDelegate: AnyObject {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, includeJSONHeaders: true, includeOauthToken: true)
var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: true)
struct CreateCollectionBody: Encodable {
var label: String
}
let encoder = JSONEncoder()
let data = try encoder.encode(CreateCollectionBody(label: label))
request.httpBody = data
try addObject(CreateCollectionBody(label: label), to: &request)
let (httpResponse, collections) = try await send(request: request, resultType: [FeedlyCollection].self)
@ -191,15 +189,13 @@ protocol FeedlyAPICallerDelegate: AnyObject {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, includeJSONHeaders: true, includeOauthToken: true)
var request = try urlRequest(path: "/v3/collections", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: true)
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
try addObject(RenameCollectionBody(id: id, label: name), to: &request)
let (httpResponse, collections) = try await send(request: request, resultType: [FeedlyCollection].self)
@ -220,7 +216,7 @@ protocol FeedlyAPICallerDelegate: AnyObject {
guard let encodedID = encodeForURLPath(id) else {
throw FeedlyAccountDelegateError.unexpectedResourceID(id)
}
let request = try urlRequest(path: "/v3/collections/\(encodedID)", method: HTTPMethod.delete, includeJSONHeaders: true, includeOauthToken: true)
let request = try urlRequest(path: "/v3/collections/\(encodedID)", method: HTTPMethod.delete, includeJSONHeaders: true, includeOAuthToken: true)
let (httpResponse, _) = try await send(request: request, resultType: Optional<FeedlyCollection>.self)
@ -229,7 +225,7 @@ protocol FeedlyAPICallerDelegate: AnyObject {
}
}
func removeFeed(_ feedId: String, fromCollectionWith collectionID: String) async throws {
func removeFeed(_ feedID: String, fromCollectionWith collectionID: String) async throws {
guard !isSuspended else { throw TransportError.suspended }
@ -246,14 +242,12 @@ protocol FeedlyAPICallerDelegate: AnyObject {
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.delete
addJSONHeaders(&request)
try addOauthAccessToken(&request)
try addOAuthAccessToken(&request)
struct RemovableFeed: Encodable {
let id: String
}
let encoder = JSONEncoder()
let data = try encoder.encode([RemovableFeed(id: feedId)])
request.httpBody = data
try addObject([RemovableFeed(id: feedID)], to: &request)
// `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
@ -283,15 +277,13 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.put
addJSONHeaders(&request)
try addOauthAccessToken(&request)
try addOAuthAccessToken(&request)
struct AddFeedBody: Encodable {
var id: String
var title: String?
}
let encoder = JSONEncoder()
let data = try encoder.encode(AddFeedBody(id: feedID.id, title: title))
request.httpBody = data
try addObject(AddFeedBody(id: feedID.id, title: title), to: &request)
let (_, collectionFeeds) = try await send(request: request, resultType: [FeedlyFeed].self)
guard let collectionFeeds else {
@ -305,6 +297,7 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
static func authorizationCodeURLRequest(for request: OAuthAuthorizationRequest, baseUrlComponents: URLComponents) -> URLRequest {
var components = baseUrlComponents
components.path = "/v3/auth/auth"
components.queryItems = request.queryItems
@ -323,154 +316,63 @@ extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
typealias AccessTokenResponse = FeedlyOAuthAccessTokenResponse
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completion: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> ()) {
guard !isSuspended else {
return DispatchQueue.main.async {
completion(.failure(TransportError.suspended))
}
}
var components = baseURLComponents
components.path = "/v3/auth/token"
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: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
do {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
request.httpBody = try encoder.encode(authorizationRequest)
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, tokenResponse)):
if let response = tokenResponse {
completion(.success(response))
} else {
completion(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completion(.failure(error))
}
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest) async throws -> FeedlyOAuthAccessTokenResponse {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/auth/token", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: false)
try addObject(authorizationRequest, keyEncodingStrategy: .convertToSnakeCase, to: &request)
let (_, tokenResponse) = try await send(request: request, resultType: AccessTokenResponse.self)
guard let tokenResponse else {
throw URLError(.cannotDecodeContentData)
}
return tokenResponse
}
}
extension FeedlyAPICaller: OAuthAcessTokenRefreshRequesting {
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completion: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> ()) {
guard !isSuspended else {
return DispatchQueue.main.async {
completion(.failure(TransportError.suspended))
}
}
var components = baseURLComponents
components.path = "/v3/auth/token"
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: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
do {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
request.httpBody = try encoder.encode(refreshRequest)
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, tokenResponse)):
if let response = tokenResponse {
completion(.success(response))
} else {
completion(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completion(.failure(error))
}
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest) async throws -> FeedlyOAuthAccessTokenResponse {
guard !isSuspended else { throw TransportError.suspended }
var request = try urlRequest(path: "/v3/auth/token", method: HTTPMethod.post, includeJSONHeaders: true, includeOAuthToken: false)
try addObject(refreshRequest, keyEncodingStrategy: .convertToSnakeCase, to: &request)
let (_, tokenResponse) = try await send(request: request, resultType: AccessTokenResponse.self)
guard let tokenResponse else {
throw URLError(.cannotDecodeContentData)
}
return tokenResponse
}
}
extension FeedlyAPICaller: FeedlyGetCollectionsService {
func getCollections(completion: @escaping @Sendable (Result<[FeedlyCollection], Error>) -> ()) {
guard !isSuspended else {
return DispatchQueue.main.async {
completion(.failure(TransportError.suspended))
}
func getCollections() async throws -> [FeedlyCollection] {
guard !isSuspended else { throw TransportError.suspended }
let request = try urlRequest(path: "/v3/collections", method: HTTPMethod.get, includeJSONHeaders: true, includeOAuthToken: true)
let (_, collections) = try await send(request: request, resultType: [FeedlyCollection].self)
guard let collections else {
throw URLError(.cannotDecodeContentData)
}
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completion(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseURLComponents
components.path = "/v3/collections"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
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: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completion(.success(response))
} else {
completion(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completion(.failure(error))
}
}
return collections
}
}
extension FeedlyAPICaller: FeedlyGetStreamContentsService {
@MainActor func getStreamContents(for resource: FeedlyResourceID, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ()) {
guard !isSuspended else {
return DispatchQueue.main.async {
completion(.failure(TransportError.suspended))
}
}
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completion(.failure(CredentialsError.incompleteCredentials))
}
}
@MainActor func getStreamContents(for resource: FeedlyResourceID, continuation: String?, newerThan: Date?, unreadOnly: Bool?) async throws -> FeedlyStream {
guard !isSuspended else { throw TransportError.suspended }
var components = baseURLComponents
components.path = "/v3/streams/contents"
@ -505,22 +407,16 @@ extension FeedlyAPICaller: FeedlyGetStreamContentsService {
}
var request = URLRequest(url: url)
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: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completion(.success(response))
} else {
completion(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completion(.failure(error))
}
addJSONHeaders(&request)
try addOAuthAccessToken(&request)
let (_, collections) = try await send(request: request, resultType: FeedlyStream.self)
guard let collections else {
throw URLError(.cannotDecodeContentData)
}
return collections
}
}
@ -809,7 +705,7 @@ extension FeedlyAPICaller: FeedlyLogoutService {
private extension FeedlyAPICaller {
func urlRequest(path: String, method: String, includeJSONHeaders: Bool, includeOauthToken: Bool) throws -> URLRequest {
func urlRequest(path: String, method: String, includeJSONHeaders: Bool, includeOAuthToken: Bool) throws -> URLRequest {
let url = apiURL(path)
var request = URLRequest(url: url)
@ -819,8 +715,8 @@ private extension FeedlyAPICaller {
if includeJSONHeaders {
addJSONHeaders(&request)
}
if includeOauthToken {
try addOauthAccessToken(&request)
if includeOAuthToken {
try addOAuthAccessToken(&request)
}
return request
@ -832,7 +728,7 @@ private extension FeedlyAPICaller {
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
}
func addOauthAccessToken(_ request: inout URLRequest) throws {
func addOAuthAccessToken(_ request: inout URLRequest) throws {
guard let accessToken = credentials?.secret else {
throw CredentialsError.incompleteCredentials
@ -852,4 +748,10 @@ private extension FeedlyAPICaller {
return url
}
func addObject<T: Encodable & Sendable>(_ object: T, keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys, to request: inout URLRequest) throws {
let data = try JSONEncoder().encode(object)
request.httpBody = data
}
}

View File

@ -12,7 +12,7 @@ import Feedly
/// Models section 6 of the OAuth 2.0 Authorization Framework
/// https://tools.ietf.org/html/rfc6749#section-6
public struct OAuthRefreshAccessTokenRequest: Encodable {
public struct OAuthRefreshAccessTokenRequest: Encodable, Sendable {
public let grantType = "refresh_token"
public var refreshToken: String
public var scope: String?
@ -37,11 +37,11 @@ public protocol OAuthAcessTokenRefreshRequesting {
/// Access tokens expire. Perform a request for a fresh access token given the long life refresh token received when authorization was granted.
/// - Parameter refreshRequest: The refresh token and other information the authorization server requires to grant the client fresh access tokens on the user's behalf.
/// - Parameter completion: On success, the access token response appropriate for concrete type's service. Both the access and refresh token should be stored, preferably on the Keychain. On failure, possibly a `URLError` or `OAuthAuthorizationErrorResponse` value.
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completion: @escaping (Result<AccessTokenResponse, Error>) -> ())
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest) async throws -> AccessTokenResponse
}
/// Implemented by concrete types to perform the actual request.
protocol OAuthAccessTokenRefreshing: AnyObject {
@MainActor func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ())
@MainActor func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient) async throws -> OAuthAuthorizationGrant
}

View File

@ -110,7 +110,7 @@ public enum OAuthAuthorizationError: String, Sendable {
/// Models section 4.1.3 of the OAuth 2.0 Authorization Framework
/// https://tools.ietf.org/html/rfc6749#section-4.1.3
public struct OAuthAccessTokenRequest: Encodable {
public struct OAuthAccessTokenRequest: Encodable, Sendable {
public let grantType = "authorization_code"
public var code: String
public var redirectUri: String
@ -157,13 +157,13 @@ public protocol OAuthAuthorizationCodeGrantRequesting {
/// Provides the URL request that allows users to consent to the client having access to their information. Typically loaded by a web view.
/// - Parameter request: The information about the client requesting authorization to be granted access tokens.
/// - Parameter baseUrlComponents: The scheme and host of the url except for the path.
static func authorizationCodeURLRequest(for request: OAuthAuthorizationRequest, baseUrlComponents: URLComponents) -> URLRequest
@MainActor static func authorizationCodeURLRequest(for request: OAuthAuthorizationRequest, baseUrlComponents: URLComponents) -> URLRequest
/// Performs the request for the access token given an authorization code.
/// - Parameter authorizationRequest: The authorization code and other information the authorization server requires to grant the client access tokens on the user's behalf.
/// - Parameter completion: On success, the access token response appropriate for concrete type's service. On failure, possibly a `URLError` or `OAuthAuthorizationErrorResponse` value.
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completion: @escaping (Result<AccessTokenResponse, Error>) -> ())
/// - Returns: On success, the access token response appropriate for concrete type's service. On failure, throws possibly a `URLError` or `OAuthAuthorizationErrorResponse` value.
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest) async throws -> FeedlyOAuthAccessTokenResponse
}
protocol OAuthAuthorizationGranting: AccountDelegate {

View File

@ -27,52 +27,34 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
}
override func run() {
let refreshToken: Credentials
do {
guard let credentials = try account.retrieveCredentials(type: .oauthRefreshToken) else {
os_log(.debug, log: log, "Could not find a refresh token in the keychain. Check the refresh token is added to the Keychain, remove the account and add it again.")
throw TransportError.httpError(status: 403)
}
refreshToken = credentials
} catch {
didFinish(with: error)
return
}
os_log(.debug, log: log, "Refreshing access token.")
// Ignore cancellation after the request is resumed otherwise we may continue storing a potentially invalid token!
service.refreshAccessToken(with: refreshToken.secret, client: oauthClient) { result in
self.didRefreshAccessToken(result)
}
}
private func didRefreshAccessToken(_ result: Result<OAuthAuthorizationGrant, Error>) {
assert(Thread.isMainThread)
switch result {
case .success(let grant):
Task { @MainActor in
do {
os_log(.debug, log: log, "Storing refresh token.")
// Store the refresh token first because it sends this token to the account delegate.
if let token = grant.refreshToken {
try account.storeCredentials(token)
guard let credentials = try account.retrieveCredentials(type: .oauthRefreshToken) else {
os_log(.debug, log: log, "Could not find a refresh token in the keychain. Check the refresh token is added to the Keychain, remove the account and add it again.")
throw TransportError.httpError(status: 403)
}
os_log(.debug, log: log, "Storing access token.")
// Ignore cancellation after the request is resumed otherwise we may continue storing a potentially invalid token!
os_log(.debug, log: log, "Refreshing access token.")
let grant = try await service.refreshAccessToken(with: credentials.secret, client: oauthClient)
// Store the refresh token first because it sends this token to the account delegate.
os_log(.debug, log: log, "Storing refresh token.")
if let refreshToken = grant.refreshToken {
try account.storeCredentials(refreshToken)
}
// Now store the access token because we want the account delegate to use it.
os_log(.debug, log: log, "Storing access token.")
try account.storeCredentials(grant.accessToken)
didFinish()
} catch {
didFinish(with: error)
}
case .failure(let error):
didFinish(with: error)
}
}
}

View File

@ -28,21 +28,19 @@ public final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollect
}
public override func run() {
os_log(.debug, log: log, "Requesting collections.")
service.getCollections { result in
MainActor.assumeIsolated {
switch result {
case .success(let collections):
os_log(.debug, log: self.log, "Received collections: %{public}@", collections.map { $0.id })
self.collections = collections
self.didFinish()
case .failure(let error):
os_log(.debug, log: self.log, "Unable to request collections: %{public}@.", error as NSError)
self.didFinish(with: error)
}
Task { @MainActor in
os_log(.debug, log: log, "Requesting collections.")
do {
let collections = try await service.getCollections()
os_log(.debug, log: self.log, "Received collections: %{public}@", collections.map { $0.id })
self.collections = collections
self.didFinish()
} catch {
os_log(.debug, log: self.log, "Unable to request collections: %{public}@.", error as NSError)
self.didFinish(with: error)
}
}
}

View File

@ -98,16 +98,17 @@ public final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntr
}
public override func run() {
service.getStreamContents(for: resourceProvider.resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in
switch result {
case .success(let stream):
Task { @MainActor in
do {
let stream = try await service.getStreamContents(for: resourceProvider.resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly)
self.stream = stream
self.streamDelegate?.feedlyGetStreamContentsOperation(self, didGetContentsOf: stream)
self.didFinish()
case .failure(let error):
} catch {
os_log(.debug, log: self.log, "Unable to get stream contents: %{public}@.", error as NSError)
self.didFinish(with: error)
}

View File

@ -9,5 +9,6 @@
import Foundation
public protocol FeedlyGetCollectionsService: AnyObject {
func getCollections(completion: @escaping @Sendable (Result<[FeedlyCollection], Error>) -> ())
@MainActor func getCollections() async throws -> [FeedlyCollection]
}

View File

@ -9,5 +9,6 @@
import Foundation
public protocol FeedlyGetStreamContentsService: AnyObject {
func getStreamContents(for resource: FeedlyResourceID, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ())
@MainActor func getStreamContents(for resource: FeedlyResourceID, continuation: String?, newerThan: Date?, unreadOnly: Bool?) async throws -> FeedlyStream
}