Add ReaderAPI and AccountError packages.

This commit is contained in:
Brent Simmons
2023-08-28 07:55:04 -07:00
parent 92623222fd
commit e9e64ad7d2
42 changed files with 530 additions and 163 deletions

8
Account/File.swift Normal file
View File

@@ -0,0 +1,8 @@
//
// File.swift
// Account
//
// Created by Brent Simmons on 8/27/23.
//
import Foundation

View File

@@ -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"])

View File

@@ -11,6 +11,7 @@ import UIKit
#endif
import Foundation
import AccountError
import RSCore
import Articles
import RSParser

View File

@@ -7,6 +7,7 @@
//
import Foundation
import AccountError
import CloudKit
import SystemConfiguration
import SyncDatabase

View File

@@ -10,6 +10,7 @@ import Foundation
import RSParser
import RSWeb
import RSCore
import AccountError
class FeedFinder {

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -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>) -> ())

View File

@@ -11,6 +11,7 @@ import SyncDatabase
import RSWeb
import RSCore
import Secrets
import AccountError
class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate, Logging {

View File

@@ -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."

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -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<Credentials?, Error>) -> 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<ParsedItem>()
}
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 were doing things the old-fashioned way.
// Hope the compiler is happy.
var parsedItems = Set<ParsedItem>()
for entry in entries {
guard let streamID = entry.origin.streamId else {
return nil
continue
}
var authors: Set<ParsedAuthor>? {
@@ -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)
}
}

9
AccountError/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@@ -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
}

View File

@@ -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"]),
]
)

3
AccountError/README.md Normal file
View File

@@ -0,0 +1,3 @@
# AccountError
A description of this package.

View File

@@ -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 {

View File

@@ -0,0 +1,2 @@
import XCTest
@testable import AccountError

View File

@@ -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,

View File

@@ -11,6 +11,7 @@ import Account
import RSWeb
import RSCore
import Secrets
import ReaderAPI
@MainActor class AccountsReaderAPIWindowController: NSWindowController, Logging {

View File

@@ -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 = "<group>"; };
8486EC3F2A9C2431007EF90D /* ReaderAPI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ReaderAPI; path = SyncClients/ReaderAPI; sourceTree = "<group>"; };
8486EC402A9C2EFE007EF90D /* AccountError */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AccountError; sourceTree = "<group>"; };
848B937121C8C5540038DC0D /* CrashReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReporter.swift; sourceTree = "<group>"; };
848D578D21543519005FFAD5 /* PasteboardFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardFeed.swift; sourceTree = "<group>"; };
848F6AE41FC29CFA002D422E /* FaviconDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FaviconDownloader.swift; sourceTree = "<group>"; };
@@ -2502,6 +2504,8 @@
849C64611ED37A5D003D8FC0 /* Products */,
51C452B22265141B00C03939 /* Frameworks */,
51CD32C624D2DEF9009ABAEF /* Account */,
8486EC402A9C2EFE007EF90D /* AccountError */,
8486EC3F2A9C2431007EF90D /* ReaderAPI */,
8486EC3E2A9BE083007EF90D /* NewsBlur */,
51CD32C424D2CF1D009ABAEF /* Articles */,
51CD32C324D2CD57009ABAEF /* ArticlesDatabase */,

View File

@@ -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
}

View File

@@ -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"))

9
SyncClients/ReaderAPI/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@@ -0,0 +1,8 @@
//
// File.swift
// ReaderAPI
//
// Created by Brent Simmons on 8/27/23.
//
import Foundation

View File

@@ -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
}

View File

@@ -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"]),
]
)

View File

@@ -0,0 +1,3 @@
# ReaderAPI
A description of this package.

View File

@@ -0,0 +1,6 @@
public struct ReaderAPI {
public private(set) var text = "Hello, World!"
public init() {
}
}

View File

@@ -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<Credentials?, Error>) -> 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<Credentials?, Error>) -> 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<Credentials?, Error>) -> 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<String, Error>) -> 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, Error>) -> Void) {
public func renameTag(oldName: String, newName: String, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> Void) {
@MainActor public func deleteTag(folderExternalID: String?, completion: @escaping (Result<Void, Error>) -> 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<CreateReaderAPISubscriptionResult, Error>) -> Void) {
public func createSubscription(url: String, name: String?, completion: @escaping (Result<CreateReaderAPISubscriptionResult, Error>) -> 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, Error>) -> Void) {
public func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
changeSubscription(subscriptionID: subscriptionID, title: newName, completion: completion)
}
func deleteSubscription(subscriptionID: String, completion: @escaping (Result<Void, Error>) -> Void) {
public func deleteSubscription(subscriptionID: String, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> Void) {
public func createTagging(subscriptionID: String, tagName: String, completion: @escaping (Result<Void, Error>) -> Void) {
changeSubscription(subscriptionID: subscriptionID, addTagName: tagName, completion: completion)
}
func deleteTagging(subscriptionID: String, tagName: String, completion: @escaping (Result<Void, Error>) -> Void) {
public func deleteTagging(subscriptionID: String, tagName: String, completion: @escaping (Result<Void, Error>) -> Void) {
changeSubscription(subscriptionID: subscriptionID, removeTagName: tagName, completion: completion)
}
func moveSubscription(subscriptionID: String, fromTag: String, toTag: String, completion: @escaping (Result<Void, Error>) -> Void) {
public func moveSubscription(subscriptionID: String, fromTag: String, toTag: String, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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, Error>) -> Void) {
public func createUnreadEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
updateStateToEntries(entries: entries, state: .read, add: false, completion: completion)
}
func deleteUnreadEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
public func deleteUnreadEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
updateStateToEntries(entries: entries, state: .read, add: true, completion: completion)
}
func createStarredEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
public func createStarredEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
updateStateToEntries(entries: entries, state: .starred, add: true, completion: completion)
}
func deleteStarredEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
public func deleteStarredEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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"

View File

@@ -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 {

View File

@@ -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.")
}
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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!")
}
}