diff --git a/Account/Package.swift b/Account/Package.swift index 85712e46a..0b2dea2dd 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -20,7 +20,8 @@ let package = Package( .package(path: "../SyncDatabase"), .package(path: "../Core"), .package(path: "../CloudKitExtras"), - .package(path: "../ReaderAPI") + .package(path: "../ReaderAPI"), + .package(path: "../CommonErrors") ], targets: [ .target( @@ -35,7 +36,8 @@ let package = Package( "Database", "Core", "CloudKitExtras", - "ReaderAPI" + "ReaderAPI", + "CommonErrors" ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") diff --git a/Account/Sources/Account/AccountError.swift b/Account/Sources/Account/AccountError.swift index 5220d4c40..af02f3d12 100644 --- a/Account/Sources/Account/AccountError.swift +++ b/Account/Sources/Account/AccountError.swift @@ -8,13 +8,11 @@ import Foundation import Web +import CommonErrors -public enum AccountError: LocalizedError { - - case createErrorNotFound - case createErrorAlreadySubscribed - case opmlImportInProgress - case wrappedError(error: Error, accountID: String, accountName: String) +typealias AccountError = CommonError // Temporary, for compatibility with existing code + +public extension CommonError { @MainActor public var account: Account? { if case .wrappedError(_, let accountID, _) = self { @@ -23,78 +21,8 @@ public enum AccountError: LocalizedError { return nil } } - + @MainActor public static func wrappedError(error: Error, account: Account) -> AccountError { wrappedError(error: error, accountID: account.accountID, accountName: account.nameForDisplay) } - - @MainActor public var isCredentialsError: Bool { - if case .wrappedError(let error, _, _) = self { - if case TransportError.httpError(let status) = error { - return isCredentialsError(status: status) - } - } - return false - } - - public var errorDescription: String? { - switch self { - case .createErrorNotFound: - return NSLocalizedString("The feed couldn’t be found and can’t be added.", comment: "Not found") - case .createErrorAlreadySubscribed: - return NSLocalizedString("You are already subscribed to this feed and can’t add it again.", comment: "Already subscribed") - case .opmlImportInProgress: - return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running") - case .wrappedError(let error, _, let accountName): - switch error { - case TransportError.httpError(let status): - if isCredentialsError(status: status) { - let localizedText = NSLocalizedString("Your “%@” credentials are invalid or expired.", comment: "Invalid or expired") - return NSString.localizedStringWithFormat(localizedText as NSString, accountName) as String - } else { - return unknownError(error, accountName) - } - default: - return unknownError(error, accountName) - } - } - } - - public var recoverySuggestion: String? { - switch self { - case .createErrorNotFound: - return nil - case .createErrorAlreadySubscribed: - return nil - case .wrappedError(let error, _, _): - switch error { - case TransportError.httpError(let status): - if isCredentialsError(status: status) { - return NSLocalizedString("Please update your credentials for this account, or ensure that your account with this service is still valid.", comment: "Expired credentials") - } else { - return NSLocalizedString("Please try again later.", comment: "Try later") - } - default: - return NSLocalizedString("Please try again later.", comment: "Try later") - } - default: - return NSLocalizedString("Please try again later.", comment: "Try later") - } - } - -} - -// MARK: Private - -private extension AccountError { - - func unknownError(_ error: Error, _ accountName: String) -> String { - let localizedText = NSLocalizedString("An error occurred while processing the “%@” account: %@", comment: "Unknown error") - return NSString.localizedStringWithFormat(localizedText as NSString, accountName, error.localizedDescription) as String - } - - func isCredentialsError(status: Int) -> Bool { - return status == 401 || status == 403 - } - } diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index 0d09a44ba..be319a563 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -16,26 +16,6 @@ import Database import Core import ReaderAPI -public enum ReaderAPIAccountDelegateError: 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.") - } - } -} - final class ReaderAPIAccountDelegate: AccountDelegate { private let variant: ReaderAPIVariant diff --git a/CommonErrors/.gitignore b/CommonErrors/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/CommonErrors/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/CommonErrors/Package.swift b/CommonErrors/Package.swift new file mode 100644 index 000000000..eced08e13 --- /dev/null +++ b/CommonErrors/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.10 + +import PackageDescription + +let package = Package( + name: "CommonErrors", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "CommonErrors", + targets: ["CommonErrors"]), + ], + dependencies: [ + .package(path: "../Web"), + ], + targets: [ + .target( + name: "CommonErrors", + dependencies: [ + "Web" + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "CommonErrorsTests", + dependencies: ["CommonErrors"]), + ] +) diff --git a/CommonErrors/Sources/CommonErrors/CommonError.swift b/CommonErrors/Sources/CommonErrors/CommonError.swift new file mode 100644 index 000000000..33f6fbe7f --- /dev/null +++ b/CommonErrors/Sources/CommonErrors/CommonError.swift @@ -0,0 +1,86 @@ +// +// CommonError.swift +// +// +// Created by Brent Simmons on 4/6/24. +// + +import Foundation +import Web + +public enum CommonError: LocalizedError { + + case createErrorNotFound + case createErrorAlreadySubscribed + case opmlImportInProgress + case wrappedError(error: Error, accountID: String, accountName: String) + + @MainActor public var isCredentialsError: Bool { + if case .wrappedError(let error, _, _) = self { + if case TransportError.httpError(let status) = error { + return isCredentialsError(status: status) + } + } + return false + } + + public var errorDescription: String? { + switch self { + case .createErrorNotFound: + return NSLocalizedString("The feed couldn’t be found and can’t be added.", comment: "Not found") + case .createErrorAlreadySubscribed: + return NSLocalizedString("You are already subscribed to this feed and can’t add it again.", comment: "Already subscribed") + case .opmlImportInProgress: + return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running") + case .wrappedError(let error, _, let accountName): + switch error { + case TransportError.httpError(let status): + if isCredentialsError(status: status) { + let localizedText = NSLocalizedString("Your “%@” credentials are invalid or expired.", comment: "Invalid or expired") + return NSString.localizedStringWithFormat(localizedText as NSString, accountName) as String + } else { + return unknownError(error, accountName) + } + default: + return unknownError(error, accountName) + } + } + } + + public var recoverySuggestion: String? { + switch self { + case .createErrorNotFound: + return nil + case .createErrorAlreadySubscribed: + return nil + case .wrappedError(let error, _, _): + switch error { + case TransportError.httpError(let status): + if isCredentialsError(status: status) { + return NSLocalizedString("Please update your credentials for this account, or ensure that your account with this service is still valid.", comment: "Expired credentials") + } else { + return NSLocalizedString("Please try again later.", comment: "Try later") + } + default: + return NSLocalizedString("Please try again later.", comment: "Try later") + } + default: + return NSLocalizedString("Please try again later.", comment: "Try later") + } + } + +} + +// MARK: Private + +private extension CommonError { + + func unknownError(_ error: Error, _ accountName: String) -> String { + let localizedText = NSLocalizedString("An error occurred while processing the “%@” account: %@", comment: "Unknown error") + return NSString.localizedStringWithFormat(localizedText as NSString, accountName, error.localizedDescription) as String + } + + func isCredentialsError(status: Int) -> Bool { + return status == 401 || status == 403 + } +} diff --git a/CommonErrors/Tests/CommonErrorsTests/CommonErrorsTests.swift b/CommonErrors/Tests/CommonErrorsTests/CommonErrorsTests.swift new file mode 100644 index 000000000..37d01e331 --- /dev/null +++ b/CommonErrors/Tests/CommonErrorsTests/CommonErrorsTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import CommonErrors + +final class CommonErrorsTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index bcdfdefd2..d51305485 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -1303,6 +1303,7 @@ 840D617E2029031C009BC708 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 840D61952029031D009BC708 /* NetNewsWire_iOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetNewsWire_iOSTests.swift; sourceTree = ""; }; 840D61972029031D009BC708 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8410C4A62BC221C900D4F799 /* CommonErrors */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = CommonErrors; sourceTree = ""; }; 841550F42B9E3F8000D4B345 /* Database */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Database; sourceTree = ""; }; 841550F52B9E4D6800D4B345 /* FMDB */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = FMDB; sourceTree = ""; }; 84162A142038C12C00035290 /* MarkCommandValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkCommandValidationStatus.swift; sourceTree = ""; }; @@ -2356,6 +2357,7 @@ 51C452B22265141B00C03939 /* Frameworks */, 51CD32C624D2DEF9009ABAEF /* Account */, 84CC98D92BC1DD25006A05C9 /* ReaderAPI */, + 8410C4A62BC221C900D4F799 /* CommonErrors */, 51CD32C424D2CF1D009ABAEF /* Articles */, 51CD32C324D2CD57009ABAEF /* ArticlesDatabase */, 51CD32C724D2E06C009ABAEF /* Secrets */, diff --git a/ReaderAPI/Package.swift b/ReaderAPI/Package.swift index a13ea45ae..8b41c8006 100644 --- a/ReaderAPI/Package.swift +++ b/ReaderAPI/Package.swift @@ -11,12 +11,20 @@ let package = Package( targets: ["ReaderAPI"]), ], dependencies: [ - .package(path: "../FoundationExtras") + .package(path: "../FoundationExtras"), + .package(path: "../Web"), + .package(path: "../Secrets"), + .package(path: "../CommonErrors"), ], targets: [ .target( name: "ReaderAPI", - dependencies: ["FoundationExtras"], + dependencies: [ + "FoundationExtras", + "Web", + "Secrets", + "CommonErrors" + ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") ] diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift b/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift similarity index 88% rename from Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift rename to ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift index 9e05028e9..06c97f2c3 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift +++ b/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift @@ -9,14 +9,23 @@ import Foundation import Web import Secrets -import ReaderAPI +import CommonErrors -enum CreateReaderAPISubscriptionResult { +public protocol ReaderAPICallerDelegate: AnyObject { + + var endpointURL: URL? { get } + + var lastArticleFetchStartTime: Date? { get set } + var lastArticleFetchEndTime: Date? { get set } +} + +public enum CreateReaderAPISubscriptionResult: Sendable { + case created(ReaderAPISubscription) case notFound } -@MainActor final class ReaderAPICaller: NSObject { +@MainActor final class ReaderAPICaller { enum ItemIDType { case unread @@ -54,7 +63,7 @@ enum CreateReaderAPISubscriptionResult { private var accessToken: String? - weak var accountMetadata: AccountMetadata? + weak var delegate: ReaderAPICallerDelegate? var variant: ReaderAPIVariant = .generic var credentials: Credentials? @@ -69,10 +78,7 @@ enum CreateReaderAPISubscriptionResult { get { switch variant { case .generic, .freshRSS: - guard let accountMetadata = accountMetadata else { - return nil - } - return accountMetadata.endpointURL + return delegate?.endpointURL default: return URL(string: variant.host) } @@ -80,14 +86,14 @@ enum CreateReaderAPISubscriptionResult { } init(transport: Transport, secretsProvider: SecretsProvider) { + self.transport = transport self.secretsProvider = secretsProvider var urlHostAllowed = CharacterSet.urlHostAllowed urlHostAllowed.remove("+") urlHostAllowed.remove("&") - uriComponentAllowed = urlHostAllowed - super.init() + self.uriComponentAllowed = urlHostAllowed } func cancelAll() { @@ -100,7 +106,7 @@ enum CreateReaderAPISubscriptionResult { throw CredentialsError.incompleteCredentials } - var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.login.rawValue), credentials: credentials) + var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.login.rawValue), readerAPICredentials: credentials) addVariantHeaders(&request) do { @@ -134,7 +140,7 @@ enum CreateReaderAPISubscriptionResult { } catch { if let transportError = error as? TransportError, case .httpError(let code) = transportError, code == 404 { - throw ReaderAPIAccountDelegateError.urlNotFound + throw ReaderAPIError.urlNotFound } else { throw error } @@ -153,7 +159,7 @@ enum CreateReaderAPISubscriptionResult { throw CredentialsError.incompleteCredentials } - var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.token.rawValue), credentials: credentials) + var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.token.rawValue), readerAPICredentials: credentials) addVariantHeaders(&request) let (_, data) = try await transport.send(request: request) @@ -185,7 +191,7 @@ enum CreateReaderAPISubscriptionResult { throw TransportError.noURL } - var request = URLRequest(url: callURL, credentials: credentials) + var request = URLRequest(url: callURL, readerAPICredentials: credentials) addVariantHeaders(&request) let (_, wrapper) = try await transport.send(request: request, resultType: ReaderAPITagContainer.self) @@ -200,13 +206,13 @@ enum CreateReaderAPISubscriptionResult { let token = try await requestAuthorizationToken(endpoint: baseURL) - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), credentials: self.credentials) + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), readerAPICredentials: 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 { - throw ReaderAPIAccountDelegateError.invalidParameter + throw ReaderAPIError.invalidParameter } let oldTagName = "user/-/label/\(encodedOldName)" @@ -217,18 +223,15 @@ enum CreateReaderAPISubscriptionResult { } - func deleteTag(folder: Folder) async throws { + func deleteTag(folderExternalID: String) async throws { guard let baseURL = apiBaseURL else { throw CredentialsError.incompleteCredentials } - guard let folderExternalID = folder.externalID else { - throw ReaderAPIAccountDelegateError.invalidParameter - } - + let token = try await self.requestAuthorizationToken(endpoint: baseURL) - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), credentials: self.credentials) + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), readerAPICredentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" @@ -252,14 +255,14 @@ enum CreateReaderAPISubscriptionResult { throw TransportError.noURL } - var request = URLRequest(url: callURL, credentials: credentials) + var request = URLRequest(url: callURL, readerAPICredentials: credentials) addVariantHeaders(&request) let (_, container) = try await transport.send(request: request, resultType: ReaderAPISubscriptionContainer.self) return container?.subscriptions } - func createSubscription(url: String, name: String?, folder: Folder?) async throws -> CreateReaderAPISubscriptionResult { + func createSubscription(url: String, name: String?) async throws -> CreateReaderAPISubscriptionResult { guard let baseURL = apiBaseURL else { throw CredentialsError.incompleteCredentials @@ -270,13 +273,13 @@ enum CreateReaderAPISubscriptionResult { let callURL = baseURL .appendingPathComponent(ReaderAPIEndpoints.subscriptionAdd.rawValue) - var request = URLRequest(url: callURL, credentials: self.credentials) + var request = URLRequest(url: callURL, readerAPICredentials: 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 { - throw ReaderAPIAccountDelegateError.invalidParameter + throw ReaderAPIError.invalidParameter } let postData = "T=\(token)&quickadd=\(encodedFeedURL)".data(using: String.Encoding.utf8) @@ -293,10 +296,10 @@ enum CreateReaderAPISubscriptionResult { // There is no call to get a single subscription entry, so we get them all, // look up the one we just subscribed to and return that guard let subscriptions = try await retrieveSubscriptions() else { - throw AccountError.createErrorNotFound + throw CommonError.createErrorNotFound } guard let subscription = subscriptions.first(where: { $0.feedID == subResult.streamID }) else { - throw AccountError.createErrorNotFound + throw CommonError.createErrorNotFound } return .created(subscription) @@ -315,7 +318,7 @@ enum CreateReaderAPISubscriptionResult { let token = try await self.requestAuthorizationToken(endpoint: baseURL) - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), readerAPICredentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" @@ -343,16 +346,15 @@ enum CreateReaderAPISubscriptionResult { private func changeSubscription(subscriptionID: String, removeTagName: String? = nil, addTagName: String? = nil, title: String? = nil) async throws { guard removeTagName != nil || addTagName != nil || title != nil else { - throw ReaderAPIAccountDelegateError.invalidParameter + throw ReaderAPIError.invalidParameter } guard let baseURL = apiBaseURL else { throw CredentialsError.incompleteCredentials - return } let token = try await requestAuthorizationToken(endpoint: baseURL) - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), readerAPICredentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" @@ -383,7 +385,7 @@ enum CreateReaderAPISubscriptionResult { let token = try await requestAuthorizationToken(endpoint: baseURL) - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials) + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), readerAPICredentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" @@ -404,7 +406,7 @@ enum CreateReaderAPISubscriptionResult { let (_, entryWrapper) = try await transport.send(request: request, method: HTTPMethod.post, data: postData!, resultType: ReaderAPIEntryWrapper.self) guard let entryWrapper else { - throw ReaderAPIAccountDelegateError.invalidResponse + throw ReaderAPIError.invalidResponse } return entryWrapper.entries @@ -424,7 +426,7 @@ enum CreateReaderAPISubscriptionResult { 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() @@ -436,7 +438,7 @@ enum CreateReaderAPISubscriptionResult { queryItems.append(URLQueryItem(name: "s", value: ReaderStreams.readingList.rawValue)) case .allForFeed: guard let feedID else { - throw ReaderAPIAccountDelegateError.invalidParameter + throw ReaderAPIError.invalidParameter } let sinceTimeInterval = (Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()).timeIntervalSince1970 queryItems.append(URLQueryItem(name: "ot", value: String(Int(sinceTimeInterval)))) @@ -456,7 +458,7 @@ enum CreateReaderAPISubscriptionResult { throw TransportError.noURL } - var request: URLRequest = URLRequest(url: callURL, credentials: credentials) + var request: URLRequest = URLRequest(url: callURL, readerAPICredentials: credentials) addVariantHeaders(&request) let (response, entries) = try await transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) @@ -475,14 +477,14 @@ enum CreateReaderAPISubscriptionResult { guard let continuation else { if type == .allForAccount { - self.accountMetadata?.lastArticleFetchStartTime = dateInfo?.date - self.accountMetadata?.lastArticleFetchEndTime = Date() + delegate?.lastArticleFetchStartTime = dateInfo?.date + delegate?.lastArticleFetchEndTime = Date() } return itemIDs } guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - throw ReaderAPIAccountDelegateError.invalidParameter + throw ReaderAPIError.invalidParameter } var queryItems = urlComponents.queryItems!.filter({ $0.name != "c" }) @@ -493,7 +495,7 @@ enum CreateReaderAPISubscriptionResult { throw TransportError.noURL } - var request: URLRequest = URLRequest(url: callURL, credentials: credentials) + var request: URLRequest = URLRequest(url: callURL, readerAPICredentials: credentials) addVariantHeaders(&request) let (_, entries) = try await self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) @@ -554,7 +556,7 @@ private extension ReaderAPICaller { let token = try await requestAuthorizationToken(endpoint: baseURL) // Do POST asking for data about all the new articles - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), credentials: self.credentials) + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), readerAPICredentials: self.credentials) self.addVariantHeaders(&request) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" diff --git a/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift b/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift new file mode 100644 index 000000000..dd3eae5ae --- /dev/null +++ b/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift @@ -0,0 +1,29 @@ +// +// ReaderAPIError.swift +// +// +// Created by Brent Simmons on 4/6/24. +// + +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/ReaderAPI/Sources/ReaderAPI/URLRequest+ReaderAPI.swift b/ReaderAPI/Sources/ReaderAPI/URLRequest+ReaderAPI.swift new file mode 100644 index 000000000..eb73b0e9e --- /dev/null +++ b/ReaderAPI/Sources/ReaderAPI/URLRequest+ReaderAPI.swift @@ -0,0 +1,55 @@ +// +// URLRequest+ReaderAPI.swift +// +// +// Created by Brent Simmons on 4/6/24. +// + +import Foundation +import Secrets +import Web + +extension URLRequest { + + init(url: URL, readerAPICredentials: Credentials?, conditionalGet: HTTPConditionalGetInfo? = nil) { + + self.init(url: url) + + guard let credentials = readerAPICredentials else { + return + } + + let credentialsType = credentials.type + precondition(credentialsType == .readerBasic || credentialsType == .readerAPIKey) + + if credentialsType == .readerBasic { + + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + httpMethod = "POST" + var postData = URLComponents() + postData.queryItems = [ + URLQueryItem(name: "Email", value: credentials.username), + URLQueryItem(name: "Passwd", value: credentials.secret) + ] + httpBody = postData.enhancedPercentEncodedQuery?.data(using: .utf8) + + } else if credentialsType == .readerAPIKey { + + let auth = "GoogleLogin auth=\(credentials.secret)" + setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) + } + + guard let conditionalGet = conditionalGet else { + return + } + + // Bug seen in the wild: lastModified with last possible 32-bit date, which is in 2038. Ignore those. + // TODO: drop this check in late 2037. + if let lastModified = conditionalGet.lastModified, !lastModified.contains("2038") { + setValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince) + } + if let etag = conditionalGet.etag { + setValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch) + } + } +}