From e9e64ad7d2d010738c457353a194427b74f7b034 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Mon, 28 Aug 2023 07:55:04 -0700 Subject: [PATCH] Add ReaderAPI and AccountError packages. --- Account/File.swift | 8 + Account/Package.swift | 6 +- Account/Sources/Account/Account.swift | 1 + .../CloudKit/CloudKitAccountDelegate.swift | 1 + .../Account/FeedFinder/FeedFinder.swift | 1 + .../Feedbin/FeedbinAccountDelegate.swift | 19 +- .../Feedly/FeedlyAccountDelegate.swift | 3 +- .../FeedlyAddFeedToCollectionOperation.swift | 1 + .../FeedlyAddNewFeedOperation.swift | 1 + .../LocalAccount/LocalAccountDelegate.swift | 1 + .../NewsBlurAccountDelegate+Internal.swift | 3 +- .../NewsBlur/NewsBlurAccountDelegate.swift | 9 +- .../ReaderAPI/ReaderAPIAccountDelegate.swift | 110 +++++--- AccountError/.gitignore | 9 + AccountError/Package.resolved | 14 ++ AccountError/Package.swift | 31 +++ AccountError/README.md | 3 + .../Sources/AccountError}/AccountError.swift | 6 +- .../Resources/en-GB.lproj/Localizable.strings | 0 .../Resources/en.lproj/Localizable.strings | 0 .../zh-Hans.lproj/Localizable.strings | 0 .../AccountErrorTests/AccountErrorTests.swift | 2 + .../AddFeed/AddFeedController.swift | 1 + .../AccountsReaderAPIWindowController.swift | 1 + NetNewsWire.xcodeproj/project.pbxproj | 4 + SyncClients/NewsBlur/Package.resolved | 32 +++ SyncClients/NewsBlur/Package.swift | 2 +- SyncClients/ReaderAPI/.gitignore | 9 + SyncClients/ReaderAPI/File.swift | 8 + SyncClients/ReaderAPI/Package.resolved | 32 +++ SyncClients/ReaderAPI/Package.swift | 38 +++ SyncClients/ReaderAPI/README.md | 3 + .../Sources/ReaderAPI/ReaderAPI.swift | 6 + .../Sources}/ReaderAPI/ReaderAPICaller.swift | 237 ++++++++++++------ .../Sources}/ReaderAPI/ReaderAPIEntry.swift | 28 +-- .../Sources/ReaderAPI/ReaderAPIError.swift | 28 +++ .../ReaderAPI/ReaderAPISubscription.swift | 16 +- .../Sources}/ReaderAPI/ReaderAPITag.swift | 8 +- .../Sources}/ReaderAPI/ReaderAPITagging.swift | 0 .../ReaderAPI/ReaderAPIUnreadEntry.swift | 0 .../Sources}/ReaderAPI/ReaderAPIVariant.swift | 0 .../Tests/ReaderAPITests/ReaderAPITests.swift | 11 + 42 files changed, 530 insertions(+), 163 deletions(-) create mode 100644 Account/File.swift create mode 100644 AccountError/.gitignore create mode 100644 AccountError/Package.resolved create mode 100644 AccountError/Package.swift create mode 100644 AccountError/README.md rename {Account/Sources/Account => AccountError/Sources/AccountError}/AccountError.swift (94%) rename {Account/Sources/Account => AccountError/Sources/AccountError}/Resources/en-GB.lproj/Localizable.strings (100%) rename {Account/Sources/Account => AccountError/Sources/AccountError}/Resources/en.lproj/Localizable.strings (100%) rename {Account/Sources/Account => AccountError/Sources/AccountError}/Resources/zh-Hans.lproj/Localizable.strings (100%) create mode 100644 AccountError/Tests/AccountErrorTests/AccountErrorTests.swift create mode 100644 SyncClients/NewsBlur/Package.resolved create mode 100644 SyncClients/ReaderAPI/.gitignore create mode 100644 SyncClients/ReaderAPI/File.swift create mode 100644 SyncClients/ReaderAPI/Package.resolved create mode 100644 SyncClients/ReaderAPI/Package.swift create mode 100644 SyncClients/ReaderAPI/README.md create mode 100644 SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPI.swift rename {Account/Sources/Account => SyncClients/ReaderAPI/Sources}/ReaderAPI/ReaderAPICaller.swift (72%) rename {Account/Sources/Account => SyncClients/ReaderAPI/Sources}/ReaderAPI/ReaderAPIEntry.swift (82%) create mode 100644 SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift rename {Account/Sources/Account => SyncClients/ReaderAPI/Sources}/ReaderAPI/ReaderAPISubscription.swift (86%) rename {Account/Sources/Account => SyncClients/ReaderAPI/Sources}/ReaderAPI/ReaderAPITag.swift (81%) rename {Account/Sources/Account => SyncClients/ReaderAPI/Sources}/ReaderAPI/ReaderAPITagging.swift (100%) rename {Account/Sources/Account => SyncClients/ReaderAPI/Sources}/ReaderAPI/ReaderAPIUnreadEntry.swift (100%) rename {Account/Sources/Account => SyncClients/ReaderAPI/Sources}/ReaderAPI/ReaderAPIVariant.swift (100%) create mode 100644 SyncClients/ReaderAPI/Tests/ReaderAPITests/ReaderAPITests.swift diff --git a/Account/File.swift b/Account/File.swift new file mode 100644 index 000000000..419dfedd1 --- /dev/null +++ b/Account/File.swift @@ -0,0 +1,8 @@ +// +// File.swift +// Account +// +// Created by Brent Simmons on 8/27/23. +// + +import Foundation diff --git a/Account/Package.swift b/Account/Package.swift index e520a8bd4..a25435770 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -10,11 +10,13 @@ var dependencies: [Package.Dependency] = [ #if swift(>=5.6) dependencies.append(contentsOf: [ + .package(path: "../AccountError"), .package(path: "../Articles"), .package(path: "../ArticlesDatabase"), .package(path: "../Secrets"), .package(path: "../SyncDatabase"), .package(path: "../SyncClients/NewsBlur"), + .package(path: "../SyncClients/ReaderAPI"), ]) #else dependencies.append(contentsOf: [ @@ -44,11 +46,13 @@ let package = Package( "RSDatabase", "RSParser", "RSWeb", + "AccountError", "Articles", "ArticlesDatabase", "Secrets", "SyncDatabase", - "NewsBlur" + "NewsBlur", + "ReaderAPI" ], linkerSettings: [ .unsafeFlags(["-Xlinker", "-no_application_extension"]) diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index 1450f62f8..5f3f91d62 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -11,6 +11,7 @@ import UIKit #endif import Foundation +import AccountError import RSCore import Articles import RSParser diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index f241abd25..7e49daf9d 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -7,6 +7,7 @@ // import Foundation +import AccountError import CloudKit import SystemConfiguration import SyncDatabase diff --git a/Account/Sources/Account/FeedFinder/FeedFinder.swift b/Account/Sources/Account/FeedFinder/FeedFinder.swift index e81c5160c..0925af811 100644 --- a/Account/Sources/Account/FeedFinder/FeedFinder.swift +++ b/Account/Sources/Account/FeedFinder/FeedFinder.swift @@ -10,6 +10,7 @@ import Foundation import RSParser import RSWeb import RSCore +import AccountError class FeedFinder { diff --git a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift index 7ba4ae4fe..412803d12 100644 --- a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift @@ -7,6 +7,7 @@ // import Articles +import AccountError import RSCore import RSDatabase import RSParser @@ -92,7 +93,7 @@ public enum FeedbinAccountDelegateError: String, Error { case .failure(let error): DispatchQueue.main.async { self.refreshProgress.clear() - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -101,7 +102,7 @@ public enum FeedbinAccountDelegateError: String, Error { case .failure(let error): DispatchQueue.main.async { self.refreshProgress.clear() - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -276,7 +277,7 @@ public enum FeedbinAccountDelegateError: String, Error { self.refreshProgress.completeTask() self.isOPMLImportInProgress = false DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -311,7 +312,7 @@ public enum FeedbinAccountDelegateError: String, Error { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -405,7 +406,7 @@ public enum FeedbinAccountDelegateError: String, Error { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -436,7 +437,7 @@ public enum FeedbinAccountDelegateError: String, Error { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) continuation.resume(throwing: wrappedError) } } @@ -464,7 +465,7 @@ public enum FeedbinAccountDelegateError: String, Error { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -511,7 +512,7 @@ public enum FeedbinAccountDelegateError: String, Error { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -1376,7 +1377,7 @@ private extension FeedbinAccountDelegate { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } diff --git a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift index 368698ccd..6ae3a1a8f 100644 --- a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift @@ -12,6 +12,7 @@ import RSParser import RSWeb import SyncDatabase import Secrets +import AccountError final class FeedlyAccountDelegate: AccountDelegate, Logging { @@ -236,7 +237,7 @@ final class FeedlyAccountDelegate: AccountDelegate, Logging { self.refreshProgress.completeTask() self.isOPMLImportInProgress = false DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift index ce749a83d..d84f1b575 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift @@ -7,6 +7,7 @@ // import Foundation +import AccountError protocol FeedlyAddFeedToCollectionService { func addFeed(with feedId: FeedlyFeedResourceId, title: String?, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> ()) diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift index 4ad145fcb..4f162c955 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift @@ -11,6 +11,7 @@ import SyncDatabase import RSWeb import RSCore import Secrets +import AccountError class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate, Logging { diff --git a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift index fd937ad87..5126eb700 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift @@ -13,6 +13,7 @@ import Articles import ArticlesDatabase import RSWeb import Secrets +import AccountError public enum LocalAccountDelegateError: String, Error { case invalidParameter = "An invalid parameter was used." diff --git a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 6ba4c88fd..60cdee0cd 100644 --- a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -14,6 +14,7 @@ import RSParser import RSWeb import SyncDatabase import NewsBlur +import AccountError extension NewsBlurAccountDelegate { @@ -527,7 +528,7 @@ extension NewsBlurAccountDelegate { case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } diff --git a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift index ce86c05bf..844a85718 100644 --- a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -14,6 +14,7 @@ import RSWeb import SyncDatabase import Secrets import NewsBlur +import AccountError final class NewsBlurAccountDelegate: AccountDelegate, Logging { @@ -95,7 +96,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging { case .failure(let error): DispatchQueue.main.async { self.refreshProgress.clear() - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -437,7 +438,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging { self.createFeed(account: account, newsBlurFeed: feed, name: name, container: container, completion: completion) case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -463,7 +464,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging { continuation.resume() case .failure(let error): - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) continuation.resume(throwing: wrappedError) } } @@ -491,7 +492,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging { case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index 13539d671..2e7641aac 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -12,6 +12,8 @@ import RSParser import RSWeb import SyncDatabase import Secrets +import ReaderAPI +import AccountError public enum ReaderAPIAccountDelegateError: LocalizedError { case unknown @@ -34,7 +36,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } @MainActor final class ReaderAPIAccountDelegate: AccountDelegate, Logging { - + private let variant: ReaderAPIVariant private let database: SyncDatabase @@ -63,20 +65,18 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } } - weak var accountMetadata: AccountMetadata? { - didSet { - caller.accountMetadata = accountMetadata - } - } - + private weak var account: Account? + weak var accountMetadata: AccountMetadata? + var refreshProgress = DownloadProgress(numberOfTasks: 0) init(dataFolder: String, transport: Transport?, variant: ReaderAPIVariant) { let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") database = SyncDatabase(databaseFilePath: databaseFilePath) - + self.variant = variant + if transport != nil { - caller = ReaderAPICaller(transport: transport!) + self.caller = ReaderAPICaller(transport: transport!) } else { let sessionConfiguration = URLSessionConfiguration.default sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData @@ -91,11 +91,11 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { sessionConfiguration.httpAdditionalHeaders = userAgentHeaders } - caller = ReaderAPICaller(transport: URLSession(configuration: sessionConfiguration)) + self.caller = ReaderAPICaller(transport: URLSession(configuration: sessionConfiguration)) } - - caller.variant = variant - self.variant = variant + + self.caller.delegate = self + self.caller.variant = variant } func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { @@ -135,7 +135,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { DispatchQueue.main.async { self.refreshProgress.clear() - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) if wrappedError.isCredentialsError, let basicCredentials = try? account.retrieveCredentials(type: .readerBasic), let endpoint = account.endpointURL { self.caller.credentials = basicCredentials @@ -311,7 +311,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -373,7 +373,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { account.removeFolder(folder) completion(.success(())) } else { - self.caller.deleteTag(folder: folder) { result in + self.caller.deleteTag(folderExternalID: folder.externalID) { result in switch result { case .success: account.removeFolder(folder) @@ -407,7 +407,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { return } - self.caller.createSubscription(url: bestFeedSpecifier.urlString, name: name, folder: container as? Folder) { result in + self.caller.createSubscription(url: bestFeedSpecifier.urlString, name: name) { result in self.refreshProgress.completeTask() switch result { case .success(let subResult): @@ -421,7 +421,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -456,7 +456,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { continuation.resume() } case .failure(let error): - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) continuation.resume(throwing: wrappedError) } } @@ -484,7 +484,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -515,7 +515,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -565,7 +565,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } case .failure(let error): DispatchQueue.main.async { - let wrappedError = WrappedAccountError(account: account, underlyingError: error) + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) completion(.failure(wrappedError)) } } @@ -665,21 +665,20 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { func accountWillBeDeleted(_ account: Account) { } - + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result) -> Void) { guard let endpoint = endpoint else { completion(.failure(TransportError.noURL)) return } - - let caller = ReaderAPICaller(transport: transport) - caller.credentials = credentials - caller.validateCredentials(endpoint: endpoint) { result in - DispatchQueue.main.async { + + ReaderAPICaller.validateCredentials(credentials: credentials, transport: transport, endpoint: endpoint, variant: .generic) { url, credentials in + URLRequest(url: url, credentials: credentials) + } completion: { result in + Task { @MainActor in completion(result) } } - } // MARK: Suspend and Resume (for iOS) @@ -1074,10 +1073,14 @@ private extension ReaderAPIAccountDelegate { guard let entries = entries else { return Set() } - - let parsedItems: [ParsedItem] = entries.compactMap { entry in + + // There was a compactMap here, but somehow the compiler got confused about returning nil + // (which is kind of the point of compactMap) so we’re doing things the old-fashioned way. + // Hope the compiler is happy. + var parsedItems = Set() + for entry in entries { guard let streamID = entry.origin.streamId else { - return nil + continue } var authors: Set? { @@ -1086,8 +1089,8 @@ private extension ReaderAPIAccountDelegate { } return Set([ParsedAuthor(name: name, url: nil, avatarURL: nil, emailAddress: nil)]) } - - return ParsedItem(syncServiceID: entry.uniqueID(variant: variant), + + let parsedItem = ParsedItem(syncServiceID: entry.uniqueID(variant: variant), uniqueID: entry.uniqueID(variant: variant), feedURL: streamID, url: nil, @@ -1104,9 +1107,10 @@ private extension ReaderAPIAccountDelegate { authors: authors, tags: nil, attachments: nil) + parsedItems.insert(parsedItem) } - - return Set(parsedItems) + + return parsedItems } @@ -1170,3 +1174,37 @@ private extension ReaderAPIAccountDelegate { } } } + +extension ReaderAPIAccountDelegate: ReaderAPICallerDelegate { + + var apiBaseURL: URL? { + switch variant { + case .generic, .freshRSS: + return accountMetadata?.endpointURL + default: + return URL(string: variant.host) + } + } + + var lastArticleFetchStartTime: Date? { + get { + account?.metadata.lastArticleFetchStartTime + } + set { + account?.metadata.lastArticleFetchStartTime = newValue + } + } + + var lastArticleFetchEndTime: Date? { + get { + account?.metadata.lastArticleFetchEndTime + } + set { + account?.metadata.lastArticleFetchEndTime = newValue + } + } + + func createURLRequest(url: URL, credentials: Secrets.Credentials?) -> URLRequest { + URLRequest(url: url, credentials: credentials) + } +} diff --git a/AccountError/.gitignore b/AccountError/.gitignore new file mode 100644 index 000000000..3b2981208 --- /dev/null +++ b/AccountError/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/AccountError/Package.resolved b/AccountError/Package.resolved new file mode 100644 index 000000000..6a2692520 --- /dev/null +++ b/AccountError/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "rsweb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSWeb.git", + "state" : { + "revision" : "aca2db763e3404757b273821f058bed2bbe02fcf", + "version" : "1.0.7" + } + } + ], + "version" : 2 +} diff --git a/AccountError/Package.swift b/AccountError/Package.swift new file mode 100644 index 000000000..edd123bc0 --- /dev/null +++ b/AccountError/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AccountError", + defaultLocalization: "en", + platforms: [.macOS(.v13), .iOS(.v16)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "AccountError", + targets: ["AccountError"]), + ], + dependencies: [ + .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "AccountError", + dependencies: [ + "RSWeb" + ]), + .testTarget( + name: "AccountErrorTests", + dependencies: ["AccountError"]), + ] +) diff --git a/AccountError/README.md b/AccountError/README.md new file mode 100644 index 000000000..93660bd80 --- /dev/null +++ b/AccountError/README.md @@ -0,0 +1,3 @@ +# AccountError + +A description of this package. diff --git a/Account/Sources/Account/AccountError.swift b/AccountError/Sources/AccountError/AccountError.swift similarity index 94% rename from Account/Sources/Account/AccountError.swift rename to AccountError/Sources/AccountError/AccountError.swift index 60661bf2a..10474e904 100644 --- a/Account/Sources/Account/AccountError.swift +++ b/AccountError/Sources/AccountError/AccountError.swift @@ -38,10 +38,10 @@ public struct WrappedAccountError: LocalizedError { private let accountNameForDisplay: String - @MainActor init(account: Account, underlyingError: Error) { - self.accountID = account.accountID + @MainActor public init(accountID: String, accountNameForDisplay: String, underlyingError: Error) { + self.accountID = accountID + self.accountNameForDisplay = accountNameForDisplay self.underlyingError = underlyingError - self.accountNameForDisplay = account.nameForDisplay var isCredentialsError = false if case TransportError.httpError(let status) = underlyingError { diff --git a/Account/Sources/Account/Resources/en-GB.lproj/Localizable.strings b/AccountError/Sources/AccountError/Resources/en-GB.lproj/Localizable.strings similarity index 100% rename from Account/Sources/Account/Resources/en-GB.lproj/Localizable.strings rename to AccountError/Sources/AccountError/Resources/en-GB.lproj/Localizable.strings diff --git a/Account/Sources/Account/Resources/en.lproj/Localizable.strings b/AccountError/Sources/AccountError/Resources/en.lproj/Localizable.strings similarity index 100% rename from Account/Sources/Account/Resources/en.lproj/Localizable.strings rename to AccountError/Sources/AccountError/Resources/en.lproj/Localizable.strings diff --git a/Account/Sources/Account/Resources/zh-Hans.lproj/Localizable.strings b/AccountError/Sources/AccountError/Resources/zh-Hans.lproj/Localizable.strings similarity index 100% rename from Account/Sources/Account/Resources/zh-Hans.lproj/Localizable.strings rename to AccountError/Sources/AccountError/Resources/zh-Hans.lproj/Localizable.strings diff --git a/AccountError/Tests/AccountErrorTests/AccountErrorTests.swift b/AccountError/Tests/AccountErrorTests/AccountErrorTests.swift new file mode 100644 index 000000000..349dec64f --- /dev/null +++ b/AccountError/Tests/AccountErrorTests/AccountErrorTests.swift @@ -0,0 +1,2 @@ +import XCTest +@testable import AccountError diff --git a/Mac/MainWindow/AddFeed/AddFeedController.swift b/Mac/MainWindow/AddFeed/AddFeedController.swift index 1f9488a24..28e50a129 100644 --- a/Mac/MainWindow/AddFeed/AddFeedController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedController.swift @@ -13,6 +13,7 @@ import RSTree import Articles import Account import RSParser +import AccountError // Run add-feed sheet. // If it returns with URL and optional name, diff --git a/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift b/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift index 1e94b8e72..e57f53b08 100644 --- a/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift @@ -11,6 +11,7 @@ import Account import RSWeb import RSCore import Secrets +import ReaderAPI @MainActor class AccountsReaderAPIWindowController: NSWindowController, Logging { diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 25fdcc6e5..036f72311 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -1380,6 +1380,8 @@ 8483630A2262A3F000DA1D35 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Mac/Base.lproj/RenameSheet.xib; sourceTree = SOURCE_ROOT; }; 8483630D2262A3FE00DA1D35 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Mac/Base.lproj/MainWindow.storyboard; sourceTree = SOURCE_ROOT; }; 8486EC3E2A9BE083007EF90D /* NewsBlur */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NewsBlur; path = SyncClients/NewsBlur; sourceTree = ""; }; + 8486EC3F2A9C2431007EF90D /* ReaderAPI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ReaderAPI; path = SyncClients/ReaderAPI; sourceTree = ""; }; + 8486EC402A9C2EFE007EF90D /* AccountError */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AccountError; sourceTree = ""; }; 848B937121C8C5540038DC0D /* CrashReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReporter.swift; sourceTree = ""; }; 848D578D21543519005FFAD5 /* PasteboardFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardFeed.swift; sourceTree = ""; }; 848F6AE41FC29CFA002D422E /* FaviconDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FaviconDownloader.swift; sourceTree = ""; }; @@ -2502,6 +2504,8 @@ 849C64611ED37A5D003D8FC0 /* Products */, 51C452B22265141B00C03939 /* Frameworks */, 51CD32C624D2DEF9009ABAEF /* Account */, + 8486EC402A9C2EFE007EF90D /* AccountError */, + 8486EC3F2A9C2431007EF90D /* ReaderAPI */, 8486EC3E2A9BE083007EF90D /* NewsBlur */, 51CD32C424D2CF1D009ABAEF /* Articles */, 51CD32C324D2CD57009ABAEF /* ArticlesDatabase */, diff --git a/SyncClients/NewsBlur/Package.resolved b/SyncClients/NewsBlur/Package.resolved new file mode 100644 index 000000000..4c0e3b60d --- /dev/null +++ b/SyncClients/NewsBlur/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "rscore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSCore.git", + "state" : { + "revision" : "55644a3a037fed14f22ee2c0b531808f95051708", + "version" : "2.0.3" + } + }, + { + "identity" : "rsparser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSParser.git", + "state" : { + "revision" : "d5b50ff78905ebfaf26dd698e0e5d3ed8269dd9b", + "version" : "2.0.3" + } + }, + { + "identity" : "rsweb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSWeb.git", + "state" : { + "revision" : "aca2db763e3404757b273821f058bed2bbe02fcf", + "version" : "1.0.7" + } + } + ], + "version" : 2 +} diff --git a/SyncClients/NewsBlur/Package.swift b/SyncClients/NewsBlur/Package.swift index fabf19ef1..6b151adbe 100644 --- a/SyncClients/NewsBlur/Package.swift +++ b/SyncClients/NewsBlur/Package.swift @@ -13,7 +13,7 @@ let package = Package( targets: ["NewsBlur"]), ], dependencies: [ - .package(path: "../Secrets"), + .package(path: "../../Secrets"), .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")) diff --git a/SyncClients/ReaderAPI/.gitignore b/SyncClients/ReaderAPI/.gitignore new file mode 100644 index 000000000..3b2981208 --- /dev/null +++ b/SyncClients/ReaderAPI/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/SyncClients/ReaderAPI/File.swift b/SyncClients/ReaderAPI/File.swift new file mode 100644 index 000000000..119c10754 --- /dev/null +++ b/SyncClients/ReaderAPI/File.swift @@ -0,0 +1,8 @@ +// +// File.swift +// ReaderAPI +// +// Created by Brent Simmons on 8/27/23. +// + +import Foundation diff --git a/SyncClients/ReaderAPI/Package.resolved b/SyncClients/ReaderAPI/Package.resolved new file mode 100644 index 000000000..4c0e3b60d --- /dev/null +++ b/SyncClients/ReaderAPI/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "rscore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSCore.git", + "state" : { + "revision" : "55644a3a037fed14f22ee2c0b531808f95051708", + "version" : "2.0.3" + } + }, + { + "identity" : "rsparser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSParser.git", + "state" : { + "revision" : "d5b50ff78905ebfaf26dd698e0e5d3ed8269dd9b", + "version" : "2.0.3" + } + }, + { + "identity" : "rsweb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSWeb.git", + "state" : { + "revision" : "aca2db763e3404757b273821f058bed2bbe02fcf", + "version" : "1.0.7" + } + } + ], + "version" : 2 +} diff --git a/SyncClients/ReaderAPI/Package.swift b/SyncClients/ReaderAPI/Package.swift new file mode 100644 index 000000000..32102ad88 --- /dev/null +++ b/SyncClients/ReaderAPI/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ReaderAPI", + platforms: [.macOS(.v13), .iOS(.v16)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "ReaderAPI", + targets: ["ReaderAPI"]), + ], + dependencies: [ + .package(path: "../../Secrets"), + .package(path: "../../AccountError"), + .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")) + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "ReaderAPI", + dependencies: [ + "AccountError", + "Secrets", + "RSWeb", + "RSParser", + "RSCore" + ]), +// .testTarget( +// name: "ReaderAPITests", +// dependencies: ["ReaderAPI"]), + ] +) diff --git a/SyncClients/ReaderAPI/README.md b/SyncClients/ReaderAPI/README.md new file mode 100644 index 000000000..b0ba0f6ed --- /dev/null +++ b/SyncClients/ReaderAPI/README.md @@ -0,0 +1,3 @@ +# ReaderAPI + +A description of this package. diff --git a/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPI.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPI.swift new file mode 100644 index 000000000..d83a0b5ac --- /dev/null +++ b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPI.swift @@ -0,0 +1,6 @@ +public struct ReaderAPI { + public private(set) var text = "Hello, World!" + + public init() { + } +} diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift similarity index 72% rename from Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift rename to SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift index cd05b27ca..52ea22229 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift +++ b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift @@ -7,17 +7,29 @@ // import Foundation +import AccountError import RSWeb import Secrets -enum CreateReaderAPISubscriptionResult { +public enum CreateReaderAPISubscriptionResult { case created(ReaderAPISubscription) case notFound } -final class ReaderAPICaller: NSObject { +public protocol ReaderAPICallerDelegate: AnyObject { + + var apiBaseURL: URL? { get } + var lastArticleFetchStartTime: Date? { get set } + var lastArticleFetchEndTime: Date? { get set } + + func createURLRequest(url: URL, credentials: Credentials?) -> URLRequest +} + +struct MissingDelegateError: Error {} + +public final class ReaderAPICaller { - enum ItemIDType { + public enum ItemIDType { case unread case starred case allForAccount @@ -47,17 +59,18 @@ final class ReaderAPICaller: NSObject { case editTag = "/reader/api/0/edit-tag" } + public weak var delegate: ReaderAPICallerDelegate? + private var transport: Transport! + private let missingDelegateError = MissingDelegateError() private let uriComponentAllowed: CharacterSet private var accessToken: String? - weak var accountMetadata: AccountMetadata? + public var variant: ReaderAPIVariant = .generic + public var credentials: Credentials? - var variant: ReaderAPIVariant = .generic - var credentials: Credentials? - - var server: String? { + public var server: String? { get { return apiBaseURL?.host } @@ -65,40 +78,27 @@ final class ReaderAPICaller: NSObject { private var apiBaseURL: URL? { get { - switch variant { - case .generic, .freshRSS: - guard let accountMetadata = accountMetadata else { - return nil - } - return accountMetadata.endpointURL - default: - return URL(string: variant.host) - } + delegate!.apiBaseURL } } - init(transport: Transport) { + public init(transport: Transport) { self.transport = transport - + var urlHostAllowed = CharacterSet.urlHostAllowed urlHostAllowed.remove("+") urlHostAllowed.remove("&") uriComponentAllowed = urlHostAllowed - super.init() } - func cancelAll() { + public func cancelAll() { transport.cancelAll() } - - func validateCredentials(endpoint: URL, completion: @escaping (Result) -> Void) { - guard let credentials = credentials else { - completion(.failure(CredentialsError.incompleteCredentials)) - return - } - - var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.login.rawValue), credentials: credentials) - addVariantHeaders(&request) + + public static func validateCredentials(credentials: Credentials, transport: Transport, endpoint: URL, variant: ReaderAPIVariant, createURLRequest: (URL, Credentials?) -> URLRequest, completion: @escaping (Result) -> Void) { + + var request = createURLRequest(endpoint.appendingPathComponent(ReaderAPIEndpoints.login.rawValue), credentials) + addVariantHeaders(&request, variant) transport.send(request: request) { result in switch result { @@ -107,13 +107,13 @@ final class ReaderAPICaller: NSObject { completion(.failure(TransportError.noData)) break } - + // Convert the return data to UTF8 and then parse out the Auth token guard let rawData = String(data: resultData, encoding: .utf8) else { completion(.failure(TransportError.noData)) break } - + var authData: [String: String] = [:] rawData.split(separator: "\n").forEach({ (line: Substring) in let items = line.split(separator: "=").map{String($0)} @@ -121,28 +121,56 @@ final class ReaderAPICaller: NSObject { authData[items[0]] = items[1] } }) - + guard let authString = authData["Auth"] else { completion(.failure(CredentialsError.incompleteCredentials)) break } - - // Save Auth Token for later use - self.credentials = Credentials(type: .readerAPIKey, username: credentials.username, secret: authString) - - completion(.success(self.credentials)) + + let validatedCredentials = Credentials(type: .readerAPIKey, username: credentials.username, secret: authString) + completion(.success(validatedCredentials)) case .failure(let error): if let transportError = error as? TransportError, case .httpError(let code) = transportError, code == 404 { - completion(.failure(ReaderAPIAccountDelegateError.urlNotFound)) + completion(.failure(ReaderAPIError.urlNotFound)) } else { completion(.failure(error)) } } } - + } + + public func validateCredentials(endpoint: URL, completion: @escaping (Result) -> Void) { + guard let delegate else { + completion(.failure(missingDelegateError)) + return + } + guard let credentials else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + Self.validateCredentials(credentials: credentials, transport: transport, endpoint: endpoint, variant: variant, createURLRequest: delegate.createURLRequest) { result in + + switch result { + + case .success(let validatedCredentials): + // Save Auth Token for later use + if let validatedCredentials { + self.credentials = validatedCredentials + } + completion(.success(validatedCredentials)) + + case .failure(let error): + completion(.failure(error)) + } + } } func requestAuthorizationToken(endpoint: URL, completion: @escaping (Result) -> Void) { + guard let delegate else { + completion(.failure(missingDelegateError)) + return + } // If we have a token already, use it if let accessToken = accessToken { completion(.success(accessToken)) @@ -155,7 +183,7 @@ final class ReaderAPICaller: NSObject { return } - var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.token.rawValue), credentials: credentials) + var request = delegate.createURLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.token.rawValue), credentials: credentials) addVariantHeaders(&request) transport.send(request: request) { result in @@ -181,7 +209,11 @@ final class ReaderAPICaller: NSObject { } - func retrieveTags(completion: @escaping (Result<[ReaderAPITag]?, Error>) -> Void) { + public func retrieveTags(completion: @escaping (Result<[ReaderAPITag]?, Error>) -> Void) { + guard let delegate else { + completion(.failure(missingDelegateError)) + return + } guard let baseURL = apiBaseURL else { completion(.failure(CredentialsError.incompleteCredentials)) return @@ -200,7 +232,7 @@ final class ReaderAPICaller: NSObject { return } - var request = URLRequest(url: callURL, credentials: credentials) + var request = delegate.createURLRequest(url: callURL, credentials: credentials) addVariantHeaders(&request) transport.send(request: request, resultType: ReaderAPITagContainer.self) { result in @@ -214,7 +246,7 @@ final class ReaderAPICaller: NSObject { } - func renameTag(oldName: String, newName: String, completion: @escaping (Result) -> Void) { + public func renameTag(oldName: String, newName: String, completion: @escaping (Result) -> Void) { guard let baseURL = apiBaseURL else { completion(.failure(CredentialsError.incompleteCredentials)) return @@ -223,13 +255,17 @@ final class ReaderAPICaller: NSObject { self.requestAuthorizationToken(endpoint: baseURL) { (result) in switch result { case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), credentials: self.credentials) + guard let delegate = self.delegate else { + completion(.failure(self.missingDelegateError)) + return + } + var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), credentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" guard let encodedOldName = self.encodeForURLPath(oldName), let encodedNewName = self.encodeForURLPath(newName) else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) + completion(.failure(ReaderAPIError.invalidParameter)) return } @@ -255,21 +291,25 @@ final class ReaderAPICaller: NSObject { } } - @MainActor func deleteTag(folder: Folder, completion: @escaping (Result) -> Void) { + @MainActor public func deleteTag(folderExternalID: String?, completion: @escaping (Result) -> Void) { guard let baseURL = apiBaseURL else { completion(.failure(CredentialsError.incompleteCredentials)) return } - guard let folderExternalID = folder.externalID else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) + guard let folderExternalID else { + completion(.failure(ReaderAPIError.invalidParameter)) return } self.requestAuthorizationToken(endpoint: baseURL) { (result) in switch result { case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), credentials: self.credentials) + guard let delegate = self.delegate else { + completion(.failure(self.missingDelegateError)) + return + } + var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), credentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" @@ -294,7 +334,11 @@ final class ReaderAPICaller: NSObject { } } - func retrieveSubscriptions(completion: @escaping (Result<[ReaderAPISubscription]?, Error>) -> Void) { + public func retrieveSubscriptions(completion: @escaping (Result<[ReaderAPISubscription]?, Error>) -> Void) { + guard let delegate else { + completion(.failure(missingDelegateError)) + return + } guard let baseURL = apiBaseURL else { completion(.failure(CredentialsError.incompleteCredentials)) return @@ -309,7 +353,7 @@ final class ReaderAPICaller: NSObject { return } - var request = URLRequest(url: callURL, credentials: credentials) + var request = delegate.createURLRequest(url: callURL, credentials: credentials) addVariantHeaders(&request) transport.send(request: request, resultType: ReaderAPISubscriptionContainer.self) { result in @@ -322,7 +366,7 @@ final class ReaderAPICaller: NSObject { } } - func createSubscription(url: String, name: String?, folder: Folder?, completion: @escaping (Result) -> Void) { + public func createSubscription(url: String, name: String?, completion: @escaping (Result) -> Void) { guard let baseURL = apiBaseURL else { completion(.failure(CredentialsError.incompleteCredentials)) return @@ -358,16 +402,20 @@ final class ReaderAPICaller: NSObject { self.requestAuthorizationToken(endpoint: baseURL) { (result) in switch result { case .success(let token): + guard let delegate = self.delegate else { + completion(.failure(self.missingDelegateError)) + return + } let callURL = baseURL .appendingPathComponent(ReaderAPIEndpoints.subscriptionAdd.rawValue) - var request = URLRequest(url: callURL, credentials: self.credentials) + var request = delegate.createURLRequest(url: callURL, credentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" guard let encodedFeedURL = self.encodeForURLPath(url) else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) + completion(.failure(ReaderAPIError.invalidParameter)) return } let postData = "T=\(token)&quickadd=\(encodedFeedURL)".data(using: String.Encoding.utf8) @@ -402,11 +450,11 @@ final class ReaderAPICaller: NSObject { } - func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result) -> Void) { + public func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result) -> Void) { changeSubscription(subscriptionID: subscriptionID, title: newName, completion: completion) } - func deleteSubscription(subscriptionID: String, completion: @escaping (Result) -> Void) { + public func deleteSubscription(subscriptionID: String, completion: @escaping (Result) -> Void) { guard let baseURL = apiBaseURL else { completion(.failure(CredentialsError.incompleteCredentials)) return @@ -415,7 +463,11 @@ final class ReaderAPICaller: NSObject { self.requestAuthorizationToken(endpoint: baseURL) { (result) in switch result { case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + guard let delegate = self.delegate else { + completion(.failure(self.missingDelegateError)) + return + } + var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" @@ -439,21 +491,21 @@ final class ReaderAPICaller: NSObject { } } - func createTagging(subscriptionID: String, tagName: String, completion: @escaping (Result) -> Void) { + public func createTagging(subscriptionID: String, tagName: String, completion: @escaping (Result) -> Void) { changeSubscription(subscriptionID: subscriptionID, addTagName: tagName, completion: completion) } - func deleteTagging(subscriptionID: String, tagName: String, completion: @escaping (Result) -> Void) { + public func deleteTagging(subscriptionID: String, tagName: String, completion: @escaping (Result) -> Void) { changeSubscription(subscriptionID: subscriptionID, removeTagName: tagName, completion: completion) } - func moveSubscription(subscriptionID: String, fromTag: String, toTag: String, completion: @escaping (Result) -> Void) { + public func moveSubscription(subscriptionID: String, fromTag: String, toTag: String, completion: @escaping (Result) -> Void) { changeSubscription(subscriptionID: subscriptionID, removeTagName: fromTag, addTagName: toTag, completion: completion) } private func changeSubscription(subscriptionID: String, removeTagName: String? = nil, addTagName: String? = nil, title: String? = nil, completion: @escaping (Result) -> Void) { guard removeTagName != nil || addTagName != nil || title != nil else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) + completion(.failure(ReaderAPIError.invalidParameter)) return } @@ -465,7 +517,11 @@ final class ReaderAPICaller: NSObject { self.requestAuthorizationToken(endpoint: baseURL) { (result) in switch result { case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + guard let delegate = self.delegate else { + completion(.failure(self.missingDelegateError)) + return + } + var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" @@ -499,7 +555,7 @@ final class ReaderAPICaller: NSObject { } } - func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([ReaderAPIEntry]?), Error>) -> Void) { + public func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([ReaderAPIEntry]?), Error>) -> Void) { guard !articleIDs.isEmpty else { completion(.success(([ReaderAPIEntry]()))) @@ -514,7 +570,11 @@ final class ReaderAPICaller: NSObject { self.requestAuthorizationToken(endpoint: baseURL) { (result) in switch result { case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials) + guard let delegate = self.delegate else { + completion(.failure(self.missingDelegateError)) + return + } + var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" @@ -536,7 +596,7 @@ final class ReaderAPICaller: NSObject { switch result { case .success(let (_, entryWrapper)): guard let entryWrapper = entryWrapper else { - completion(.failure(ReaderAPIAccountDelegateError.invalidResponse)) + completion(.failure(ReaderAPIError.invalidResponse)) return } @@ -554,7 +614,11 @@ final class ReaderAPICaller: NSObject { } - func retrieveItemIDs(type: ItemIDType, feedID: String? = nil, completion: @escaping ((Result<[String], Error>) -> Void)) { + public func retrieveItemIDs(type: ItemIDType, feedID: String? = nil, completion: @escaping ((Result<[String], Error>) -> Void)) { + guard let delegate else { + completion(.failure(missingDelegateError)) + return + } guard let baseURL = apiBaseURL else { completion(.failure(CredentialsError.incompleteCredentials)) return @@ -568,7 +632,7 @@ final class ReaderAPICaller: NSObject { switch type { case .allForAccount: let since: Date = { - if let lastArticleFetch = self.accountMetadata?.lastArticleFetchStartTime { + if let lastArticleFetch = delegate.lastArticleFetchStartTime { return lastArticleFetch } else { return Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() @@ -580,7 +644,7 @@ final class ReaderAPICaller: NSObject { queryItems.append(URLQueryItem(name: "s", value: ReaderStreams.readingList.rawValue)) case .allForFeed: guard let feedID = feedID else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) + completion(.failure(ReaderAPIError.invalidParameter)) return } let sinceTimeInterval = (Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()).timeIntervalSince1970 @@ -602,7 +666,7 @@ final class ReaderAPICaller: NSObject { return } - var request: URLRequest = URLRequest(url: callURL, credentials: credentials) + var request = delegate.createURLRequest(url: callURL, credentials: credentials) addVariantHeaders(&request) self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in @@ -624,15 +688,15 @@ final class ReaderAPICaller: NSObject { func retrieveItemIDs(type: ItemIDType, url: URL, dateInfo: HTTPDateInfo?, itemIDs: [String], continuation: String?, completion: @escaping ((Result<[String], Error>) -> Void)) { guard let continuation = continuation else { if type == .allForAccount { - self.accountMetadata?.lastArticleFetchStartTime = dateInfo?.date - self.accountMetadata?.lastArticleFetchEndTime = Date() + self.delegate?.lastArticleFetchStartTime = dateInfo?.date + self.delegate?.lastArticleFetchEndTime = Date() } completion(.success(itemIDs)) return } guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) + completion(.failure(ReaderAPIError.invalidParameter)) return } @@ -644,8 +708,11 @@ final class ReaderAPICaller: NSObject { completion(.failure(TransportError.noURL)) return } - - var request: URLRequest = URLRequest(url: callURL, credentials: credentials) + guard let delegate else { + completion(.failure(missingDelegateError)) + return + } + var request = delegate.createURLRequest(url: callURL, credentials: credentials) addVariantHeaders(&request) self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in @@ -664,19 +731,19 @@ final class ReaderAPICaller: NSObject { } } - func createUnreadEntries(entries: [String], completion: @escaping (Result) -> Void) { + public func createUnreadEntries(entries: [String], completion: @escaping (Result) -> Void) { updateStateToEntries(entries: entries, state: .read, add: false, completion: completion) } - func deleteUnreadEntries(entries: [String], completion: @escaping (Result) -> Void) { + public func deleteUnreadEntries(entries: [String], completion: @escaping (Result) -> Void) { updateStateToEntries(entries: entries, state: .read, add: true, completion: completion) } - func createStarredEntries(entries: [String], completion: @escaping (Result) -> Void) { + public func createStarredEntries(entries: [String], completion: @escaping (Result) -> Void) { updateStateToEntries(entries: entries, state: .starred, add: true, completion: completion) } - func deleteStarredEntries(entries: [String], completion: @escaping (Result) -> Void) { + public func deleteStarredEntries(entries: [String], completion: @escaping (Result) -> Void) { updateStateToEntries(entries: entries, state: .starred, add: false, completion: completion) } @@ -691,13 +758,17 @@ private extension ReaderAPICaller { return pathComponent.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed) } - func addVariantHeaders(_ request: inout URLRequest) { + static func addVariantHeaders(_ request: inout URLRequest, _ variant: ReaderAPIVariant) { if variant == .inoreader { request.addValue(SecretsManager.provider.inoreaderAppId, forHTTPHeaderField: "AppId") request.addValue(SecretsManager.provider.inoreaderAppKey, forHTTPHeaderField: "AppKey") } } + func addVariantHeaders(_ request: inout URLRequest) { + Self.addVariantHeaders(&request, variant) + } + private func updateStateToEntries(entries: [String], state: ReaderState, add: Bool, completion: @escaping (Result) -> Void) { guard let baseURL = apiBaseURL else { completion(.failure(CredentialsError.incompleteCredentials)) @@ -708,7 +779,11 @@ private extension ReaderAPICaller { switch result { case .success(let token): // Do POST asking for data about all the new articles - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), credentials: self.credentials) + guard let delegate = self.delegate else { + completion(.failure(self.missingDelegateError)) + return + } + var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), credentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIEntry.swift similarity index 82% rename from Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift rename to SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIEntry.swift index 93de146f8..f7cd8c70a 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift +++ b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIEntry.swift @@ -47,20 +47,20 @@ struct ReaderAPIEntryWrapper: Codable { } } */ -struct ReaderAPIEntry: Codable { +public struct ReaderAPIEntry: Codable { let articleID: String - let title: String? - let author: String? + public let title: String? + public let author: String? let publishedTimestamp: Double? let crawledTimestamp: String? let timestampUsec: String? - let summary: ReaderAPIArticleSummary - let alternates: [ReaderAPIAlternateLocation]? + public let summary: ReaderAPIArticleSummary + public let alternates: [ReaderAPIAlternateLocation]? let categories: [String] - let origin: ReaderAPIEntryOrigin + public let origin: ReaderAPIEntryOrigin enum CodingKeys: String, CodingKey { case articleID = "id" @@ -75,14 +75,14 @@ struct ReaderAPIEntry: Codable { case timestampUsec = "timestampUsec" } - func parseDatePublished() -> Date? { + public func parseDatePublished() -> Date? { guard let unixTime = publishedTimestamp else { return nil } return Date(timeIntervalSince1970: unixTime) } - func uniqueID(variant: ReaderAPIVariant) -> String { + public func uniqueID(variant: ReaderAPIVariant) -> String { // Should look something like "tag:google.com,2005:reader/item/00058b10ce338909" // REGEX feels heavy, I should be able to just split on / and take the last element @@ -104,24 +104,24 @@ struct ReaderAPIEntry: Codable { } -struct ReaderAPIArticleSummary: Codable { - let content: String? +public struct ReaderAPIArticleSummary: Codable { + public let content: String? enum CodingKeys: String, CodingKey { case content = "content" } } -struct ReaderAPIAlternateLocation: Codable { - let url: String? +public struct ReaderAPIAlternateLocation: Codable { + public let url: String? enum CodingKeys: String, CodingKey { case url = "href" } } -struct ReaderAPIEntryOrigin: Codable { - let streamId: String? +public struct ReaderAPIEntryOrigin: Codable { + public let streamId: String? let title: String? enum CodingKeys: String, CodingKey { diff --git a/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift new file mode 100644 index 000000000..c8720e98e --- /dev/null +++ b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift @@ -0,0 +1,28 @@ +// +// ReaderAPIError.swift +// +// +// Created by Jeremy Beker on 5/28/19. +// + +import Foundation + +public enum ReaderAPIError: LocalizedError { + case unknown + case invalidParameter + case invalidResponse + case urlNotFound + + public var errorDescription: String? { + switch self { + case .unknown: + return NSLocalizedString("An unexpected error occurred.", comment: "An unexpected error occurred.") + case .invalidParameter: + return NSLocalizedString("An invalid parameter was passed.", comment: "An invalid parameter was passed.") + case .invalidResponse: + return NSLocalizedString("There was an invalid response from the server.", comment: "There was an invalid response from the server.") + case .urlNotFound: + return NSLocalizedString("The API URL wasn't found.", comment: "The API URL wasn't found.") + } + } +} diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPISubscription.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPISubscription.swift similarity index 86% rename from Account/Sources/Account/ReaderAPI/ReaderAPISubscription.swift rename to SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPISubscription.swift index e74491ca6..9b315509a 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPISubscription.swift +++ b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPISubscription.swift @@ -55,12 +55,12 @@ struct ReaderAPISubscriptionContainer: Codable { } */ -struct ReaderAPISubscription: Codable { - let feedID: String - let name: String? - let categories: [ReaderAPICategory] +public struct ReaderAPISubscription: Codable { + public let feedID: String + public let name: String? + public let categories: [ReaderAPICategory] let feedURL: String? - let homePageURL: String? + public let homePageURL: String? let iconURL: String? enum CodingKeys: String, CodingKey { @@ -72,7 +72,7 @@ struct ReaderAPISubscription: Codable { case iconURL = "iconUrl" } - var url: String { + public var url: String { if let feedURL = feedURL { return feedURL } else { @@ -81,8 +81,8 @@ struct ReaderAPISubscription: Codable { } } -struct ReaderAPICategory: Codable { - let categoryId: String +public struct ReaderAPICategory: Codable { + public let categoryId: String let categoryLabel: String enum CodingKeys: String, CodingKey { diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPITag.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPITag.swift similarity index 81% rename from Account/Sources/Account/ReaderAPI/ReaderAPITag.swift rename to SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPITag.swift index d64462701..b65647655 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPITag.swift +++ b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPITag.swift @@ -16,17 +16,17 @@ struct ReaderAPITagContainer: Codable { } } -struct ReaderAPITag: Codable { +public struct ReaderAPITag: Codable { - let tagID: String - let type: String? + public let tagID: String + public let type: String? enum CodingKeys: String, CodingKey { case tagID = "id" case type = "type" } - var folderName: String? { + public var folderName: String? { guard let range = tagID.range(of: "/label/") else { return nil } diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPITagging.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPITagging.swift similarity index 100% rename from Account/Sources/Account/ReaderAPI/ReaderAPITagging.swift rename to SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPITagging.swift diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIUnreadEntry.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIUnreadEntry.swift similarity index 100% rename from Account/Sources/Account/ReaderAPI/ReaderAPIUnreadEntry.swift rename to SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIUnreadEntry.swift diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIVariant.swift b/SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIVariant.swift similarity index 100% rename from Account/Sources/Account/ReaderAPI/ReaderAPIVariant.swift rename to SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIVariant.swift diff --git a/SyncClients/ReaderAPI/Tests/ReaderAPITests/ReaderAPITests.swift b/SyncClients/ReaderAPI/Tests/ReaderAPITests/ReaderAPITests.swift new file mode 100644 index 000000000..182bf50ff --- /dev/null +++ b/SyncClients/ReaderAPI/Tests/ReaderAPITests/ReaderAPITests.swift @@ -0,0 +1,11 @@ +import XCTest +@testable import ReaderAPI + +final class ReaderAPITests: XCTestCase { + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + XCTAssertEqual(ReaderAPI().text, "Hello, World!") + } +}