diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index 02acd64b376..60ab88efc96 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -697,6 +697,8 @@ DEC51AFB2769C66B009F3DF4 /* SystemStatusMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */; }; DEC51B02276AFB35009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */; }; E12552C526385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12552C426385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift */; }; + E137619929151C7400FD098F /* error-wp-rest-forbidden.json in Resources */ = {isa = PBXBuildFile; fileRef = E137619829151C7400FD098F /* error-wp-rest-forbidden.json */; }; + E137619B2915222100FD098F /* WordPressApiValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E137619A2915222100FD098F /* WordPressApiValidatorTests.swift */; }; E13BAD5328F8625600217769 /* InAppPurchasesRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13BAD5228F8625600217769 /* InAppPurchasesRemoteTests.swift */; }; E16C59B528F8640B007D55BB /* InAppPurchaseOrderResultMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16C59B428F8640B007D55BB /* InAppPurchaseOrderResultMapper.swift */; }; E16C59B728F92782007D55BB /* iap-sample-receipt.json in Resources */ = {isa = PBXBuildFile; fileRef = E16C59B628F92782007D55BB /* iap-sample-receipt.json */; }; @@ -706,6 +708,10 @@ E18152C228F85E0A0011A0EC /* iap-products.json in Resources */ = {isa = PBXBuildFile; fileRef = E18152C128F85E0A0011A0EC /* iap-products.json */; }; E18152C428F85E5C0011A0EC /* InAppPurchasesProductsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18152C328F85E5C0011A0EC /* InAppPurchasesProductsMapperTests.swift */; }; E1A5C27228F93ED900081046 /* InAppPurchaseOrderResultMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A5C27128F93ED900081046 /* InAppPurchaseOrderResultMapperTests.swift */; }; + E1BAB2C12913F99500C3982B /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C02913F99500C3982B /* Request.swift */; }; + E1BAB2C32913FA6400C3982B /* ResponseDataValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C22913FA6400C3982B /* ResponseDataValidator.swift */; }; + E1BAB2C52913FB1800C3982B /* WordPressApiValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C42913FB1800C3982B /* WordPressApiValidator.swift */; }; + E1BAB2C72913FB5800C3982B /* WordPressApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BAB2C62913FB5800C3982B /* WordPressApiError.swift */; }; EE8A86F1286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json in Resources */ = {isa = PBXBuildFile; fileRef = EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */; }; EECB7EE8286555180028C888 /* media-update-product-id.json in Resources */ = {isa = PBXBuildFile; fileRef = EECB7EE7286555180028C888 /* media-update-product-id.json */; }; FE28F6E226840DED004465C7 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28F6E126840DED004465C7 /* User.swift */; }; @@ -1435,6 +1441,8 @@ DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStatusMapperTests.swift; sourceTree = ""; }; DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+DropinMustUsePlugin.swift"; sourceTree = ""; }; E12552C426385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelAddressValidationSuccess.swift; sourceTree = ""; }; + E137619829151C7400FD098F /* error-wp-rest-forbidden.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "error-wp-rest-forbidden.json"; sourceTree = ""; }; + E137619A2915222100FD098F /* WordPressApiValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiValidatorTests.swift; sourceTree = ""; }; E13BAD5228F8625600217769 /* InAppPurchasesRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchasesRemoteTests.swift; sourceTree = ""; }; E16C59B428F8640B007D55BB /* InAppPurchaseOrderResultMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseOrderResultMapper.swift; sourceTree = ""; }; E16C59B628F92782007D55BB /* iap-sample-receipt.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "iap-sample-receipt.json"; sourceTree = ""; }; @@ -1444,6 +1452,10 @@ E18152C128F85E0A0011A0EC /* iap-products.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "iap-products.json"; sourceTree = ""; }; E18152C328F85E5C0011A0EC /* InAppPurchasesProductsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchasesProductsMapperTests.swift; sourceTree = ""; }; E1A5C27128F93ED900081046 /* InAppPurchaseOrderResultMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseOrderResultMapperTests.swift; sourceTree = ""; }; + E1BAB2C02913F99500C3982B /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; + E1BAB2C22913FA6400C3982B /* ResponseDataValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseDataValidator.swift; sourceTree = ""; }; + E1BAB2C42913FB1800C3982B /* WordPressApiValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiValidator.swift; sourceTree = ""; }; + E1BAB2C62913FB5800C3982B /* WordPressApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiError.swift; sourceTree = ""; }; EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id-in-wordpress-site.json"; sourceTree = ""; }; EECB7EE7286555180028C888 /* media-update-product-id.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id.json"; sourceTree = ""; }; F3F25DC15EC1D7C631169CB5 /* Pods_Networking.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Networking.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1734,6 +1746,8 @@ isa = PBXGroup; children = ( B53EF5332180F646003E146F /* DotcomValidator.swift */, + E1BAB2C42913FB1800C3982B /* WordPressApiValidator.swift */, + E1BAB2C22913FA6400C3982B /* ResponseDataValidator.swift */, ); path = Validators; sourceTree = ""; @@ -1850,6 +1864,7 @@ isa = PBXGroup; children = ( B567AF2420A0CCA300AB6C62 /* AuthenticatedRequest.swift */, + E1BAB2C02913F99500C3982B /* Request.swift */, B557DA0E20975E07005962F4 /* DotcomRequest.swift */, B557D9FF209754FF005962F4 /* JetpackRequest.swift */, DE34051228BDCA5100CF0D97 /* WordPressOrgRequest.swift */, @@ -1952,6 +1967,7 @@ 3105470B262E27F000C5C02B /* WCPayPaymentIntentStatusEnum.swift */, 0359EA0E27AAC6410048DE2D /* WCPayPaymentMethodDetails.swift */, 0359EA1027AAC6740048DE2D /* WCPayPaymentMethodType.swift */, + E1BAB2C62913FB5800C3982B /* WordPressApiError.swift */, ); path = Model; sourceTree = ""; @@ -2205,6 +2221,7 @@ 68F48B1228E3E5750045C15B /* wc-analytics-customers.json */, 688908AD28FF920C0081A07E /* customer-2.json */, 03EB99952907F03000F06A39 /* empty-data-array.json */, + E137619829151C7400FD098F /* error-wp-rest-forbidden.json */, ); path = Responses; sourceTree = ""; @@ -2321,6 +2338,7 @@ isa = PBXGroup; children = ( B57B1E6921C925280046E764 /* DotcomValidatorTests.swift */, + E137619A2915222100FD098F /* WordPressApiValidatorTests.swift */, ); path = Validators; sourceTree = ""; @@ -2653,6 +2671,7 @@ B5A24179217F98F600595DEF /* notifications-load-all.json in Resources */, 314EDF2927C02CC100A56B6F /* stripe-account-complete-empty-descriptor.json in Resources */, 028CB71F2902589E00331C09 /* create-account-error-invalid-email.json in Resources */, + E137619929151C7400FD098F /* error-wp-rest-forbidden.json in Resources */, 31A451CE27863A2E00FE81AA /* stripe-account-wrong-json.json in Resources */, 028CB718290223CB00331C09 /* account-username-suggestions.json in Resources */, 0282DD91233A120A006A5FDB /* products-search-photo.json in Resources */, @@ -3074,6 +3093,7 @@ 743057B5218B6ACD00441A76 /* Queue.swift in Sources */, DE2095BD27956D7900171F1C /* CouponReport.swift in Sources */, 74A1D26821189A7100931DFA /* SiteVisitStats.swift in Sources */, + E1BAB2C12913F99500C3982B /* Request.swift in Sources */, 02C254B0256378D000A04423 /* ShippingLabelRefundStatus.swift in Sources */, 4568E2242459D3230007E478 /* Post.swift in Sources */, 4513382427A951B300AE5E78 /* InboxNoteMapper.swift in Sources */, @@ -3176,6 +3196,7 @@ DE34051728BDEB6D00CF0D97 /* JetpackConnectionRemote.swift in Sources */, B505F6EC20BEFDC200BB1B69 /* Loader.swift in Sources */, 74D3BD142114FE6900A6E85E /* MIContainer.swift in Sources */, + E1BAB2C72913FB5800C3982B /* WordPressApiError.swift in Sources */, 314703082670222500EF253A /* PaymentGatewayAccount.swift in Sources */, CCF48B282628A4EB0034EA83 /* ShippingLabelAccountSettingsMapper.swift in Sources */, B5BB1D1220A255EC00112D92 /* OrderStatusEnum.swift in Sources */, @@ -3192,6 +3213,7 @@ 74C8F06820EEB7BD00B6EDC9 /* OrderNotesMapper.swift in Sources */, 24F98C582502EA8800F49B68 /* FeatureFlagMapper.swift in Sources */, 451A9832260B9D2D0059D135 /* ShippingLabelPackagesMapper.swift in Sources */, + E1BAB2C32913FA6400C3982B /* ResponseDataValidator.swift in Sources */, 4501068F2399B19500E24722 /* TaxClassRemote.swift in Sources */, B53EF5342180F646003E146F /* DotcomValidator.swift in Sources */, CCF434642906BD7200B4475A /* ProductIDMapper.swift in Sources */, @@ -3222,6 +3244,7 @@ 740211E321939C84002248DA /* CommentResultMapper.swift in Sources */, CC9A24F42642CF37005DE56E /* ShippingLabelCreationEligibilityMapper.swift in Sources */, 45E3EEBB237009CF00A826AC /* ShippingLine.swift in Sources */, + E1BAB2C52913FB1800C3982B /* WordPressApiValidator.swift in Sources */, CE6BFEEA2236E191005C79FB /* ProductType.swift in Sources */, CE6D666C2379E19D007835A1 /* Array+Woo.swift in Sources */, CCF48B1E26288FEC0034EA83 /* ShippingLabelPaymentMethod.swift in Sources */, @@ -3347,6 +3370,7 @@ 74C8F06E20EEC1E800B6EDC9 /* OrderNotesMapperTests.swift in Sources */, 45ED4F10239E8A54004F1BE3 /* TaxClassListMapperTest.swift in Sources */, FE28F6EA26842E49004465C7 /* UserMapperTests.swift in Sources */, + E137619B2915222100FD098F /* WordPressApiValidatorTests.swift in Sources */, 020C907F24C7D359001E2BEB /* ProductVariationMapperTests.swift in Sources */, 74ABA1D5213F26B300FFAD30 /* TopEarnerStatsMapperTests.swift in Sources */, 74AB5B5121AF426D00859C12 /* SiteAPIRemoteTests.swift in Sources */, diff --git a/Networking/Networking/Model/WordPressApiError.swift b/Networking/Networking/Model/WordPressApiError.swift new file mode 100644 index 00000000000..df37f4220d5 --- /dev/null +++ b/Networking/Networking/Model/WordPressApiError.swift @@ -0,0 +1,61 @@ +import Foundation + +/// WordPress API Request Error +/// +public enum WordPressApiError: Error, Decodable, Equatable { + + /// Unknown: Represents an unmapped remote error. + /// + case unknown(code: String, message: String) + + /// Decodable Initializer. + /// + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let code = try container.decode(String.self, forKey: .code) + let message = try container.decode(String.self, forKey: .message) + + switch code { + default: + self = .unknown(code: code, message: message) + } + } + + + /// Constants for Possible Error Identifiers + /// + private enum Constants { + } + + /// Coding Keys + /// + private enum CodingKeys: String, CodingKey { + case code + case message + } + + /// Possible Error Messages + /// + private enum ErrorMessages { + static let statsModuleDisabled = "This blog does not have the Stats module enabled" + static let noStatsPermission = "user cannot view stats" + static let resourceDoesNotExist = "Resource does not exist." + } +} + + +// MARK: - CustomStringConvertible Conformance +// +extension WordPressApiError: CustomStringConvertible { + + public var description: String { + switch self { + case .unknown(let code, let message): + let messageFormat = NSLocalizedString( + "WordPress API Error: [%1$@] %2$@", + comment: "WordPress API (unmapped!) error. Parameters: %1$@ - code, %2$@ - message" + ) + return String.localizedStringWithFormat(messageFormat, code, message) + } + } +} diff --git a/Networking/Networking/Remote/InAppPurchasesRemote.swift b/Networking/Networking/Remote/InAppPurchasesRemote.swift index 9615bc386ba..11f6560baf0 100644 --- a/Networking/Networking/Remote/InAppPurchasesRemote.swift +++ b/Networking/Networking/Remote/InAppPurchasesRemote.swift @@ -10,8 +10,7 @@ public class InAppPurchasesRemote: Remote { /// - completion: Closure to be executed upon completion /// public func loadProducts(completion: @escaping (Swift.Result<[String], Error>) -> Void) { - let dotComRequest = DotcomRequest(wordpressApiVersion: .wpcomMark2, method: .get, path: Constants.productsPath) - let request = augmentedRequestWithAppId(dotComRequest) + let request = DotcomRequest(wordpressApiVersion: .wpcomMark2, method: .get, path: Constants.productsPath, headers: headersWithAppId) let mapper = InAppPurchasesProductMapper() enqueue(request, mapper: mapper, completion: completion) } @@ -39,8 +38,13 @@ public class InAppPurchasesRemote: Remote { Constants.appStoreCountryCodeKey: appStoreCountryCode, Constants.receiptDataKey: receiptData.base64EncodedString() ] - let dotComRequest = DotcomRequest(wordpressApiVersion: .wpcomMark2, method: .post, path: Constants.ordersPath, parameters: parameters) - let request = augmentedRequestWithAppId(dotComRequest) + let request = DotcomRequest( + wordpressApiVersion: .wpcomMark2, + method: .post, + path: Constants.ordersPath, + parameters: parameters, + headers: headersWithAppId + ) let mapper = InAppPurchaseOrderResultMapper() enqueue(request, mapper: mapper, completion: completion) } @@ -93,15 +97,14 @@ public extension InAppPurchasesRemote { } private extension InAppPurchasesRemote { - func augmentedRequestWithAppId(_ request: URLRequestConvertible) -> URLRequestConvertible { - guard let bundleIdentifier = Bundle.main.bundleIdentifier, - var augmented = try? request.asURLRequest() else { - return request + var headersWithAppId: [String: String]? { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + return nil } - augmented.setValue(bundleIdentifier, forHTTPHeaderField: "X-APP-ID") - - return augmented + return [ + "X-APP-ID": bundleIdentifier + ] } } diff --git a/Networking/Networking/Remote/Remote.swift b/Networking/Networking/Remote/Remote.swift index 56d857b133f..f3897e7f6ed 100644 --- a/Networking/Networking/Remote/Remote.swift +++ b/Networking/Networking/Remote/Remote.swift @@ -26,23 +26,20 @@ public class Remote: NSObject { /// /// - Parameter request: Request that should be performed. /// - Returns: The result from the JSON parsed response for the expected type. - func enqueue(_ request: URLRequestConvertible) async throws -> T { + func enqueue(_ request: Request) async throws -> T { try await withCheckedThrowingContinuation { continuation in network.responseData(for: request) { [weak self] result in guard let self else { return } switch result { case .success(let data): - if let dotcomError = DotcomValidator.error(from: data) { - self.dotcomErrorWasReceived(error: dotcomError, for: request) - continuation.resume(throwing: dotcomError) - return - } - do { + let validator = request.responseDataValidator() + try validator.validate(data: data) let document = try JSONDecoder().decode(T.self, from: data) continuation.resume(returning: document) } catch { + self.handleResponseError(error: error, for: request) continuation.resume(throwing: error) } case .failure(let error): @@ -63,7 +60,7 @@ public class Remote: NSObject { /// - mapper: Mapper entity that will be used to attempt to parse the Backend's Response. /// - completion: Closure to be executed upon completion. /// - func enqueue(_ request: URLRequestConvertible, mapper: M, completion: @escaping (M.Output?, Error?) -> Void) { + func enqueue(_ request: Request, mapper: M, completion: @escaping (M.Output?, Error?) -> Void) { network.responseData(for: request) { [weak self] (data, networkError) in guard let self = self else { return @@ -74,16 +71,13 @@ public class Remote: NSObject { return } - if let dotcomError = DotcomValidator.error(from: data) { - self.dotcomErrorWasReceived(error: dotcomError, for: request) - completion(nil, dotcomError) - return - } - do { + let validator = request.responseDataValidator() + try validator.validate(data: data) let parsed = try mapper.map(response: data) completion(parsed, nil) } catch { + self.handleResponseError(error: error, for: request) DDLogError("<> Mapping Error: \(error)") completion(nil, error) } @@ -100,7 +94,7 @@ public class Remote: NSObject { /// - mapper: Mapper entity that will be used to attempt to parse the Backend's Response. /// - completion: Closure to be executed upon completion. /// - func enqueue(_ request: URLRequestConvertible, mapper: M, + func enqueue(_ request: Request, mapper: M, completion: @escaping (Result) -> Void) { network.responseData(for: request) { [weak self] result in guard let self = self else { @@ -109,16 +103,13 @@ public class Remote: NSObject { switch result { case .success(let data): - if let dotcomError = DotcomValidator.error(from: data) { - self.dotcomErrorWasReceived(error: dotcomError, for: request) - completion(.failure(dotcomError)) - return - } - do { + let validator = request.responseDataValidator() + try validator.validate(data: data) let parsed = try mapper.map(response: data) completion(.success(parsed)) } catch { + self.handleResponseError(error: error, for: request) DDLogError("<> Mapping Error: \(error)") completion(.failure(error)) } @@ -138,16 +129,14 @@ public class Remote: NSObject { /// - mapper: Mapper entity that will be used to attempt to parse the Backend's Response. /// /// - Returns: A publisher that emits result upon completion. - func enqueue(_ request: URLRequestConvertible, mapper: M) -> AnyPublisher, Never> { + func enqueue(_ request: Request, mapper: M) -> AnyPublisher, Never> { network.responseDataPublisher(for: request) .map { (result: Result) -> Result in switch result { case .success(let data): - if let dotcomError = DotcomValidator.error(from: data) { - return .failure(dotcomError) - } - do { + let validator = request.responseDataValidator() + try validator.validate(data: data) let parsed = try mapper.map(response: data) return .success(parsed) } catch { @@ -160,7 +149,7 @@ public class Remote: NSObject { } .handleEvents(receiveOutput: { [weak self] result in if let dotcomError = result.failure as? DotcomError { - self?.dotcomErrorWasReceived(error: dotcomError, for: request) + self?.handleResponseError(error: dotcomError, for: request) } }) .eraseToAnyPublisher() @@ -177,7 +166,7 @@ public class Remote: NSObject { /// - multipartFormData: Used for appending data for multipart form data uploads. /// - completion: Closure to be executed upon completion. /// - func enqueueMultipartFormDataUpload(_ request: URLRequestConvertible, + func enqueueMultipartFormDataUpload(_ request: Request, mapper: M, multipartFormData: @escaping (MultipartFormData) -> Void, completion: @escaping (Result) -> Void) { @@ -192,16 +181,13 @@ public class Remote: NSObject { return } - if let dotcomError = DotcomValidator.error(from: data) { - self.dotcomErrorWasReceived(error: dotcomError, for: request) - completion(.failure(dotcomError)) - return - } - do { + let validator = request.responseDataValidator() + try validator.validate(data: data) let parsed = try mapper.map(response: data) completion(.success(parsed)) } catch { + self.handleResponseError(error: error, for: request) DDLogError("<> Mapping Error: \(error)") completion(.failure(error)) } @@ -215,24 +201,21 @@ public class Remote: NSObject { /// /// - Parameter request: Request that should be performed. /// - Returns: The result from the JSON parsed response for the expected type. - func enqueue(_ request: URLRequestConvertible, mapper: M) async throws -> Result { - try await withCheckedThrowingContinuation { continuation in + func enqueue(_ request: Request, mapper: M) async -> Result { + await withCheckedContinuation { continuation in network.responseData(for: request) { [weak self] result in guard let self else { return } switch result { case .success(let data): - if let dotcomError = DotcomValidator.error(from: data) { - self.dotcomErrorWasReceived(error: dotcomError, for: request) - continuation.resume(throwing: dotcomError) - return - } - do { + let validator = request.responseDataValidator() + try validator.validate(data: data) let parsed = try mapper.map(response: data) continuation.resume(returning: .success(parsed)) } catch { DDLogError("<> Mapping Error: \(error)") + self.handleResponseError(error: error, for: request) continuation.resume(returning: .failure(error)) } case .failure(let error): @@ -250,7 +233,7 @@ private extension Remote { /// Handles *all* of the DotcomError(s) that are successfully parsed. /// - func dotcomErrorWasReceived(error: Error, for request: URLRequestConvertible) { + func handleResponseError(error: Error, for request: Request) { guard let dotcomError = error as? DotcomError else { return } diff --git a/Networking/Networking/Requests/DotcomRequest.swift b/Networking/Networking/Requests/DotcomRequest.swift index f1a1430a16f..6c3b28f7e7d 100644 --- a/Networking/Networking/Requests/DotcomRequest.swift +++ b/Networking/Networking/Requests/DotcomRequest.swift @@ -4,7 +4,7 @@ import Alamofire /// Represents a WordPress.com Request /// -struct DotcomRequest: URLRequestConvertible { +struct DotcomRequest: Request { /// WordPress.com API Version /// @@ -22,6 +22,9 @@ struct DotcomRequest: URLRequestConvertible { /// let parameters: [String: Any]? + /// HTTP Headers + let headers: [String: String] + /// Designated Initializer. /// @@ -31,19 +34,29 @@ struct DotcomRequest: URLRequestConvertible { /// - path: RPC that should be executed. /// - parameters: Collection of String parameters to be passed over to our target RPC. /// - init(wordpressApiVersion: WordPressAPIVersion, method: HTTPMethod, path: String, parameters: [String: Any]? = nil) { + init(wordpressApiVersion: WordPressAPIVersion, method: HTTPMethod, path: String, parameters: [String: Any]? = nil, headers: [String: String]? = nil) { self.wordpressApiVersion = wordpressApiVersion self.method = method self.path = path self.parameters = parameters ?? [:] + self.headers = headers ?? [:] } /// Returns a URLRequest instance representing the current WordPress.com Request. /// func asURLRequest() throws -> URLRequest { let dotcomURL = URL(string: Settings.wordpressApiBaseURL + wordpressApiVersion.path + path)! - let dotcomRequest = try URLRequest(url: dotcomURL, method: method, headers: nil) + let dotcomRequest = try URLRequest(url: dotcomURL, method: method, headers: headers) return try URLEncoding.default.encode(dotcomRequest, with: parameters) } + + func responseDataValidator() -> ResponseDataValidator { + switch wordpressApiVersion { + case .mark1_1, .mark1_2: + return DotcomValidator() + case .wpcomMark2, .wpMark2: + return WordPressApiValidator() + } + } } diff --git a/Networking/Networking/Requests/JetpackRequest.swift b/Networking/Networking/Requests/JetpackRequest.swift index f4c12e6b85c..4ca6fa9b51b 100644 --- a/Networking/Networking/Requests/JetpackRequest.swift +++ b/Networking/Networking/Requests/JetpackRequest.swift @@ -4,7 +4,7 @@ import Alamofire /// Represents a Jetpack-Tunneled WordPress.com Endpoint /// -struct JetpackRequest: URLRequestConvertible { +struct JetpackRequest: Request { /// WordPress.com API Version: By Default, we'll go thru Mark 1.1. /// @@ -60,6 +60,10 @@ struct JetpackRequest: URLRequestConvertible { return try dotcomEncoder.encode(dotcomRequest, with: dotcomParams) } + + func responseDataValidator() -> ResponseDataValidator { + return DotcomValidator() + } } diff --git a/Networking/Networking/Requests/Request.swift b/Networking/Networking/Requests/Request.swift new file mode 100644 index 00000000000..8e2cf1e8c9e --- /dev/null +++ b/Networking/Networking/Requests/Request.swift @@ -0,0 +1,14 @@ +import Alamofire + +protocol Request: URLRequestConvertible { + /// Returns a URL request or throws if an `Error` was encountered. + /// + /// - throws: An `Error` if the underlying `URLRequest` is `nil`. + /// + /// - returns: A URL request. + func asURLRequest() throws -> URLRequest + + /// Returns a closure that tries to parse a response looking for an error + /// + func responseDataValidator() -> ResponseDataValidator +} diff --git a/Networking/Networking/Requests/WordPressOrgRequest.swift b/Networking/Networking/Requests/WordPressOrgRequest.swift index 61c6a2adbd1..7f8e2c72d9f 100644 --- a/Networking/Networking/Requests/WordPressOrgRequest.swift +++ b/Networking/Networking/Requests/WordPressOrgRequest.swift @@ -3,7 +3,7 @@ import Alamofire /// Represents a WordPress.org REST API Endpoint /// -struct WordPressOrgRequest: URLRequestConvertible { +struct WordPressOrgRequest: Request { /// Base URL for the endpoint /// @@ -30,6 +30,10 @@ struct WordPressOrgRequest: URLRequestConvertible { return try URLEncoding.default.encode(request, with: parameters) } + + func responseDataValidator() -> ResponseDataValidator { + return WordPressApiValidator() + } } private extension WordPressOrgRequest { diff --git a/Networking/Networking/Validators/DotcomValidator.swift b/Networking/Networking/Validators/DotcomValidator.swift index fb7531bd1ca..a89404c02cb 100644 --- a/Networking/Networking/Validators/DotcomValidator.swift +++ b/Networking/Networking/Validators/DotcomValidator.swift @@ -3,11 +3,13 @@ import Foundation /// WordPress.com Response Validator /// -struct DotcomValidator { - - /// Returns the DotcomError contained in a given Data Instance (if any). +struct DotcomValidator: ResponseDataValidator { + /// Throws a DotcomError contained in a given Data Instance (if any). /// - static func error(from response: Data) -> Error? { - return try? JSONDecoder().decode(DotcomError.self, from: response) + func validate(data: Data) throws { + guard let error = try? JSONDecoder().decode(DotcomError.self, from: data) else { + return + } + throw error } } diff --git a/Networking/Networking/Validators/ResponseDataValidator.swift b/Networking/Networking/Validators/ResponseDataValidator.swift new file mode 100644 index 00000000000..9cf697b2294 --- /dev/null +++ b/Networking/Networking/Validators/ResponseDataValidator.swift @@ -0,0 +1,5 @@ +protocol ResponseDataValidator { + /// Throws an error contained in a given Data Instance (if any). + /// + func validate(data: Data) throws -> Void +} diff --git a/Networking/Networking/Validators/WordPressApiValidator.swift b/Networking/Networking/Validators/WordPressApiValidator.swift new file mode 100644 index 00000000000..493cb838b61 --- /dev/null +++ b/Networking/Networking/Validators/WordPressApiValidator.swift @@ -0,0 +1,10 @@ +struct WordPressApiValidator: ResponseDataValidator { + /// Throws a WordPressApiError contained in a given Data Instance (if any). + /// + func validate(data: Data) throws { + guard let error = try? JSONDecoder().decode(WordPressApiError.self, from: data) else { + return + } + throw error + } +} diff --git a/Networking/NetworkingTests/Remote/RemoteTests.swift b/Networking/NetworkingTests/Remote/RemoteTests.swift index 3b844a06ad8..3c7e90c9bb5 100644 --- a/Networking/NetworkingTests/Remote/RemoteTests.swift +++ b/Networking/NetworkingTests/Remote/RemoteTests.swift @@ -32,10 +32,10 @@ final class RemoteTests: XCTestCase { remote.enqueue(request, mapper: mapper) { (payload, error) in guard case NetworkError.notFound? = error, - let receivedRequest = network.requestsForResponseData.first as? JetpackRequest - else { - XCTFail() - return + let receivedRequest = network.requestsForResponseData.first as? JetpackRequest + else { + XCTFail() + return } XCTAssertNil(payload) @@ -95,10 +95,10 @@ final class RemoteTests: XCTestCase { // Then let error = try XCTUnwrap(result.failure) guard case NetworkError.notFound = error, - let receivedRequest = network.requestsForResponseData.first as? JetpackRequest - else { - XCTFail() - return + let receivedRequest = network.requestsForResponseData.first as? JetpackRequest + else { + XCTFail() + return } XCTAssertEqual(network.requestsForResponseData.count, 1) @@ -263,6 +263,228 @@ final class RemoteTests: XCTestCase { XCTAssertTrue(result.isFailure) XCTAssertEqual(result.failure as? DotcomError, DotcomError.requestFailed) } + + /// Verifies that dotcom v1.1 request parses DotcomError + /// + func test_dotcom_request_v1_1_parses_dotcom_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "timeout_error") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssert(error is DotcomError) + } + + /// Verifies that dotcom v1.1 request doesn't parse WordPressApiError + /// + func test_dotcom_request_v1_1_does_not_parse_wordpress_api_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "error-wp-rest-forbidden") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + XCTAssert(result.isSuccess) + } + + /// Verifies that dotcom v1.2 request parses DotcomError + /// + func test_dotcom_request_v1_2_parses_dotcom_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = DotcomRequest(wordpressApiVersion: .mark1_2, method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "timeout_error") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssert(error is DotcomError) + } + + /// Verifies that dotcom v1.2 request doesn't parse WordPressApiError + /// + func test_dotcom_request_v1_2_does_not_parse_wordpress_api_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = DotcomRequest(wordpressApiVersion: .mark1_2, method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "error-wp-rest-forbidden") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + XCTAssert(result.isSuccess) + } + + /// Verifies that dotcom wpcom v2 request parses WordPressApiError + /// + func test_dotcom_request_wpcom_v2_parses_wordpress_api_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = DotcomRequest(wordpressApiVersion: .wpcomMark2, method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "error-wp-rest-forbidden") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssert(error is WordPressApiError) + } + + /// Verifies that dotcom wpcom v2 request doesn't parse DotcomError + /// + func test_dotcom_request_wpcom_v2_does_not_parse_dotcom_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = DotcomRequest(wordpressApiVersion: .wpcomMark2, method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "timeout_error") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + XCTAssert(result.isSuccess) + } + + /// Verifies that dotcom wp v2 request parses WordPressApiError + /// + func test_dotcom_request_wp_v2_parses_wordpress_api_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = DotcomRequest(wordpressApiVersion: .wpMark2, method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "error-wp-rest-forbidden") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssert(error is WordPressApiError) + } + + /// Verifies that dotcom wp v2 request doesn't parse DotcomError + /// + func test_dotcom_request_wp_v2_does_not_parse_dotcom_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = DotcomRequest(wordpressApiVersion: .wpMark2, method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "timeout_error") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + XCTAssert(result.isSuccess) + } + + /// Verifies that Jetpack request parses DotcomError + /// + func test_jetpack_request_parses_dotcom_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: 123, path: "mock", parameters: [:]) + + network.simulateResponse(requestUrlSuffix: "mock", filename: "timeout_error") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssert(error is DotcomError) + } + + /// Verifies that Jetpack request doesn't parse WordPressApiError + /// + func test_jetpack_request_does_not_parse_wordpress_api_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: 123, path: "mock", parameters: [:]) + + network.simulateResponse(requestUrlSuffix: "mock", filename: "error-wp-rest-forbidden") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + XCTAssert(result.isSuccess) + } + + /// Verifies that WordPressOrg request parses WordPressApiError + /// + func test_wordpress_org_request_parses_wordpress_api_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = WordPressOrgRequest(baseURL: "https://example.com", method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "error-wp-rest-forbidden") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssert(error is WordPressApiError) + } + + /// Verifies that WordPressOrg request doesn't parse DotcomError + /// + func test_wordpress_org_request_does_not_parse_dotcom_error() async throws { + // Given + let network = MockNetwork() + let mapper = DummyMapper() + let remote = Remote(network: network) + let request = WordPressOrgRequest(baseURL: "https://example.com", method: .get, path: "mock") + + network.simulateResponse(requestUrlSuffix: "mock", filename: "timeout_error") + + // When + let result = await remote.enqueue(request, mapper: mapper) + + // Then + XCTAssert(result.isSuccess) + } } diff --git a/Networking/NetworkingTests/Responses/error-wp-rest-forbidden.json b/Networking/NetworkingTests/Responses/error-wp-rest-forbidden.json new file mode 100644 index 00000000000..6b783cf14ef --- /dev/null +++ b/Networking/NetworkingTests/Responses/error-wp-rest-forbidden.json @@ -0,0 +1,7 @@ +{ + "code": "rest_forbidden", + "message": "Sorry, you are not allowed to do that.", + "data": { + "status": 401 + } +} diff --git a/Networking/NetworkingTests/Validators/DotcomValidatorTests.swift b/Networking/NetworkingTests/Validators/DotcomValidatorTests.swift index 2d293061ba0..c5e3e311a91 100644 --- a/Networking/NetworkingTests/Validators/DotcomValidatorTests.swift +++ b/Networking/NetworkingTests/Validators/DotcomValidatorTests.swift @@ -9,52 +9,66 @@ class DotcomValidatorTests: XCTestCase { /// Verifies that the DotcomValidator successfully extracts the Dotcom Error contained within a `Data` instance. /// func testGenericErrorIsProperlyExtractedFromData() { - guard let payloadAsData = Loader.contentsOf("generic_error", extension: "json"), - let dotcomError = DotcomValidator.error(from: payloadAsData) as? DotcomError + guard let payloadAsData = Loader.contentsOf("generic_error", extension: "json") else { - XCTFail() - return + return XCTFail() } - XCTAssert(dotcomError == .unauthorized) + XCTAssertThrowsError(try DotcomValidator().validate(data: payloadAsData)) { error in + guard let dotcomError = error as? DotcomError else { + return XCTFail() + } + XCTAssertEqual(dotcomError, .unauthorized) + } } /// Verifies that the DotcomValidator successfully extracts the rest_no_route Dotcom Error contained within a `Data` instance. /// func testRestNoRouteErrorIsProperlyExtractedFromData() { - guard let payloadAsData = Loader.contentsOf("restnoroute_error", extension: "json"), - let dotcomError = DotcomValidator.error(from: payloadAsData) as? DotcomError + guard let payloadAsData = Loader.contentsOf("restnoroute_error", extension: "json") else { - XCTFail() - return + return XCTFail() + } + + XCTAssertThrowsError(try DotcomValidator().validate(data: payloadAsData)) { error in + guard let dotcomError = error as? DotcomError else { + return XCTFail() + } + XCTAssertEqual(dotcomError, .noRestRoute) } - XCTAssert(dotcomError == .noRestRoute) } /// Verifies that the DotcomValidator successfully extracts the `noStatsPermission` Dotcom Error contained within a `Data` instance. /// func testNoStatsPermissionErrorIsProperlyExtractedFromData() { - guard let payloadAsData = Loader.contentsOf("no_stats_permission_error", extension: "json"), - let dotcomError = DotcomValidator.error(from: payloadAsData) as? DotcomError + guard let payloadAsData = Loader.contentsOf("no_stats_permission_error", extension: "json") else { - XCTFail() - return + return XCTFail() + } + + XCTAssertThrowsError(try DotcomValidator().validate(data: payloadAsData)) { error in + guard let dotcomError = error as? DotcomError else { + return XCTFail() + } + XCTAssertEqual(dotcomError, .noStatsPermission) } - XCTAssert(dotcomError == .noStatsPermission) } /// Verifies that the DotcomValidator successfully extracts the `statsModuleDisabled` Dotcom Error contained within a `Data` instance. /// func testStatsModuleDisabledErrorIsProperlyExtractedFromData() { - guard let payloadAsData = Loader.contentsOf("stats_module_disabled_error", extension: "json"), - let dotcomError = DotcomValidator.error(from: payloadAsData) as? DotcomError + guard let payloadAsData = Loader.contentsOf("stats_module_disabled_error", extension: "json") else { - XCTFail() - return + return XCTFail() } - XCTAssert(dotcomError == .statsModuleDisabled) + XCTAssertThrowsError(try DotcomValidator().validate(data: payloadAsData)) { error in + guard let dotcomError = error as? DotcomError else { + return XCTFail() + } + XCTAssertEqual(dotcomError, .statsModuleDisabled) + } } } diff --git a/Networking/NetworkingTests/Validators/WordPressApiValidatorTests.swift b/Networking/NetworkingTests/Validators/WordPressApiValidatorTests.swift new file mode 100644 index 00000000000..8d61962f978 --- /dev/null +++ b/Networking/NetworkingTests/Validators/WordPressApiValidatorTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import Networking + + +/// WordPressApiValidator Unit Tests +/// +class WordPressApiValidatorTests: XCTestCase { + + /// Verifies that the DotcomValidator successfully extracts the Dotcom Error contained within a `Data` instance. + /// + func testForbiddenErrorIsProperlyExtractedFromData() { + guard let payloadAsData = Loader.contentsOf("error-wp-rest-forbidden", extension: "json") + else { + return XCTFail() + } + + XCTAssertThrowsError(try WordPressApiValidator().validate(data: payloadAsData)) { error in + guard let wpApiError = error as? WordPressApiError else { + return XCTFail() + } + XCTAssertEqual(wpApiError, .unknown(code: "rest_forbidden", message: "Sorry, you are not allowed to do that.")) + } + } +}