From 37d47e908c4bc52993ba76eef2b0cdf6fa85962b Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Tue, 29 Mar 2022 13:54:10 -0700 Subject: [PATCH] Created `ErrorResponse` to abstract error deserialization This is duplicated in most of the `NetworkOperation`s. This new shared implementation will be used by `HTTPClient`. --- RevenueCat.xcodeproj/project.pbxproj | 4 + Sources/Networking/HTTPResponse.swift | 67 ++++++ Sources/Networking/HTTPStatusCode.swift | 4 + .../Handling/UserInfoAttributeParser.swift | 5 +- .../Networking/HTTPResponseTests.swift | 227 ++++++++++++++++++ .../Networking/HTTPStatusCodeTests.swift | 18 ++ 6 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 Tests/UnitTests/Networking/HTTPResponseTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 47ee4b2be6..1475920951 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -255,6 +255,7 @@ 57C381DC27961547009E3940 /* SK2StoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C381DB27961547009E3940 /* SK2StoreProductDiscount.swift */; }; 57C381E2279627B7009E3940 /* MockStoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C381E1279627B7009E3940 /* MockStoreProductDiscount.swift */; }; 57C381E3279627B7009E3940 /* MockStoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C381E1279627B7009E3940 /* MockStoreProductDiscount.swift */; }; + 57D04BB827D947C6006DAC06 /* HTTPResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D04BB727D947C6006DAC06 /* HTTPResponseTests.swift */; }; 57DC9F4627CC2E4900DA6AF9 /* HTTPRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DC9F4527CC2E4900DA6AF9 /* HTTPRequest.swift */; }; 57DC9F4A27CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DC9F4927CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift */; }; 57E0473B277260DE0082FE91 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 57E0473A277260DE0082FE91 /* SnapshotTesting */; }; @@ -676,6 +677,7 @@ 57C381D92796153D009E3940 /* SK1StoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK1StoreProductDiscount.swift; sourceTree = ""; }; 57C381DB27961547009E3940 /* SK2StoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2StoreProductDiscount.swift; sourceTree = ""; }; 57C381E1279627B7009E3940 /* MockStoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreProductDiscount.swift; sourceTree = ""; }; + 57D04BB727D947C6006DAC06 /* HTTPResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponseTests.swift; sourceTree = ""; }; 57DC9F4527CC2E4900DA6AF9 /* HTTPRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequest.swift; sourceTree = ""; }; 57DC9F4927CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatusCodeTests.swift; sourceTree = ""; }; 57E0474B27729A1E0082FE91 /* __Snapshots__ */ = {isa = PBXFileReference; lastKnownFileType = folder; path = __Snapshots__; sourceTree = ""; }; @@ -1400,6 +1402,7 @@ B380D69A27726AB500984578 /* DNSCheckerTests.swift */, 576C8AD827D2BCB90058FA6E /* HTTPRequestTests.swift */, 57DC9F4927CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift */, + 57D04BB727D947C6006DAC06 /* HTTPResponseTests.swift */, ); path = Networking; sourceTree = ""; @@ -2313,6 +2316,7 @@ 35D8330A262FBA9A00E60AC5 /* MockUserDefaults.swift in Sources */, 2DDF41DF24F6F527005BC22D /* MockProductsManager.swift in Sources */, 351B514F26D44ACE00BD2BD7 /* PurchasesSubscriberAttributesTests.swift in Sources */, + 57D04BB827D947C6006DAC06 /* HTTPResponseTests.swift in Sources */, 5796A38127D6B78500653165 /* BaseBackendTest.swift in Sources */, 351B516226D44BEE00BD2BD7 /* CustomerInfoManagerTests.swift in Sources */, 351B51A326D450BC00BD2BD7 /* DictionaryExtensionsTests.swift in Sources */, diff --git a/Sources/Networking/HTTPResponse.swift b/Sources/Networking/HTTPResponse.swift index 37cc82081d..dc16a86793 100644 --- a/Sources/Networking/HTTPResponse.swift +++ b/Sources/Networking/HTTPResponse.swift @@ -30,3 +30,70 @@ extension HTTPResponse: CustomStringConvertible { } } + +// MARK: - + +/// The response content of a failed request. +struct ErrorResponse { + + let code: BackendErrorCode + let message: String? + let attributeErrors: [String: String] + +} + +extension ErrorResponse { + + /// Converts this `ErrorResponse` into an `ErrorCode` backed by the corresponding `BackendErrorCode`. + func asBackendError(with statusCode: HTTPStatusCode) -> Error { + var userInfo: [NSError.UserInfoKey: Any] = [ + ErrorDetails.finishableKey: !statusCode.isServerError, + Backend.RCSuccessfullySyncedKey: statusCode.isSuccessfullySynced + ] + + if !self.attributeErrors.isEmpty { + userInfo[Backend.RCAttributeErrorsKey as NSError.UserInfoKey] = self.attributeErrors + } + + return ErrorUtils.backendError( + withBackendCode: self.code, + backendMessage: self.message, + extraUserInfo: userInfo + ) + } + +} + +extension ErrorResponse: Decodable { + + private enum CodingKeys: String, CodingKey { + case code + case message + case attributeErrors + } + + private struct AttributeError: Decodable { + let keyName: String + let message: String + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let codeAsInteger = try? container.decodeIfPresent(Int.self, forKey: .code) + let codeAsString = try? container.decodeIfPresent(String.self, forKey: .code) + + self.code = BackendErrorCode(code: codeAsInteger ?? codeAsString) + self.message = try container.decodeIfPresent(String.self, forKey: .message) + + let attributeErrors = ( + try? container.decodeIfPresent(Array.self, + forKey: .attributeErrors) + ) ?? [] + + self.attributeErrors = attributeErrors + .dictionaryAllowingDuplicateKeys { $0.keyName } + .mapValues { $0.message } + } + +} diff --git a/Sources/Networking/HTTPStatusCode.swift b/Sources/Networking/HTTPStatusCode.swift index 45b7575a7e..bbfb74bd15 100644 --- a/Sources/Networking/HTTPStatusCode.swift +++ b/Sources/Networking/HTTPStatusCode.swift @@ -83,4 +83,8 @@ extension HTTPStatusCode { return 500...599 ~= self.rawValue } + var isSuccessfullySynced: Bool { + return !(self.isServerError || self == .notFoundError) + } + } diff --git a/Sources/Networking/Operations/Handling/UserInfoAttributeParser.swift b/Sources/Networking/Operations/Handling/UserInfoAttributeParser.swift index 0c3cea1aa4..66c8101826 100644 --- a/Sources/Networking/Operations/Handling/UserInfoAttributeParser.swift +++ b/Sources/Networking/Operations/Handling/UserInfoAttributeParser.swift @@ -17,11 +17,8 @@ enum UserInfoAttributeParser { static func attributesUserInfoFromResponse(response: [String: Any], statusCode: HTTPStatusCode) -> [String: Any] { var resultDict: [String: Any] = [:] - let isServerError = statusCode.isServerError - let isNotFoundError = statusCode == .notFoundError - let successfullySynced = !(isServerError || isNotFoundError) - resultDict[Backend.RCSuccessfullySyncedKey as String] = successfullySynced + resultDict[Backend.RCSuccessfullySyncedKey as String] = statusCode.isSuccessfullySynced let hasAttributesResponseContainerKey = (response[Backend.RCAttributeErrorsResponseKey] != nil) let attributesResponseDict = hasAttributesResponseContainerKey diff --git a/Tests/UnitTests/Networking/HTTPResponseTests.swift b/Tests/UnitTests/Networking/HTTPResponseTests.swift new file mode 100644 index 0000000000..91b0cb3eb7 --- /dev/null +++ b/Tests/UnitTests/Networking/HTTPResponseTests.swift @@ -0,0 +1,227 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HTTPResponseTests.swift +// +// Created by Nacho Soto on 3/9/22. + +import Nimble +import XCTest + +@testable import RevenueCat + +class ErrorResponseTests: XCTestCase { + + func testNormalErrorResponse() throws { + let result = try self.decode(Self.withoutAttributeErrors) + expect(result.code) == .invalidAuthToken + expect(result.message) == "Invalid auth token." + expect(result.attributeErrors).to(beEmpty()) + } + + func testNormalErrorResponseCreatesBackendError() throws { + let error = try self.decode(Self.withoutAttributeErrors) + .asBackendError(with: .internalServerError) as NSError + + expect(error.domain) == ErrorCode.errorDomain + expect(error.code) == ErrorCode.invalidCredentialsError.rawValue + expect(error.userInfo[ErrorDetails.finishableKey as String] as? Bool) == false + expect(error.userInfo[Backend.RCAttributeErrorsKey]).to(beNil()) + + let underlyingError = try XCTUnwrap(error.userInfo[NSUnderlyingErrorKey] as? NSError) + + expect(underlyingError.domain) == "RevenueCat.BackendErrorCode" + expect(underlyingError.code) == BackendErrorCode.invalidAuthToken.rawValue + } + + func testErrorResponseWithAttributeErrors() throws { + let result = try self.decode(Self.withAttributeErrors) + expect(result.code) == .invalidSubscriberAttributes + expect(result.message) == "Some subscriber attributes keys were unable to be saved." + expect(result.attributeErrors) == [ + "$email": "Email address is not a valid email." + ] + } + + func testErrorResponseWithAttributeErrorsInInvalidFormat() throws { + let result = try self.decode(Self.withAttributeErrorsInInvalidFormat) + expect(result.code) == .invalidSubscriberAttributes + expect(result.message) == "Some subscriber attributes keys were unable to be saved." + expect(result.attributeErrors).to(beEmpty()) + } + + func testUnknownErrorCreatesBackendError() throws { + let error = try self.decode(Self.unknownError) + .asBackendError(with: .internalServerError) as NSError + + expect(error.domain) == ErrorCode.errorDomain + expect(error.code) == ErrorCode.unknownBackendError.rawValue + expect(error.userInfo[ErrorDetails.finishableKey as String] as? Bool) == false + expect(error.userInfo[Backend.RCAttributeErrorsKey]).to(beNil()) + + let underlyingError = try XCTUnwrap(error.userInfo[NSUnderlyingErrorKey] as? NSError) + + expect(underlyingError.domain) == "RevenueCat.BackendErrorCode" + expect(underlyingError.code) == BackendErrorCode.unknownBackendError.rawValue + } + + func testErrorWithOnlyMessageCreatesBackendError() throws { + let error = try self.decode(Self.onlyMessageError) + .asBackendError(with: .notFoundError) as NSError + + expect(error.domain) == ErrorCode.errorDomain + expect(error.code) == ErrorCode.unknownBackendError.rawValue + expect(error.userInfo[ErrorDetails.finishableKey as String] as? Bool) == true + expect(error.userInfo[Backend.RCAttributeErrorsKey]).to(beNil()) + + let underlyingError = try XCTUnwrap(error.userInfo[NSUnderlyingErrorKey] as? NSError) + + expect(underlyingError.domain) == "RevenueCat.BackendErrorCode" + expect(underlyingError.code) == BackendErrorCode.unknownBackendError.rawValue + } + + func testErrorWithAttributeErrorsCreatesBackendError() throws { + let error = try self.decode(Self.withAttributeErrors) + .asBackendError(with: .invalidRequest) as NSError + + expect(error.domain) == ErrorCode.errorDomain + expect(error.code) == ErrorCode.invalidSubscriberAttributesError.rawValue + expect(error.userInfo[ErrorDetails.finishableKey as String] as? Bool) == true + expect(error.userInfo[Backend.RCAttributeErrorsKey] as? [String: String]) == [ + "$email": "Email address is not a valid email." + ] + + let underlyingError = try XCTUnwrap(error.userInfo[NSUnderlyingErrorKey] as? NSError) + + expect(underlyingError.domain) == "RevenueCat.BackendErrorCode" + expect(underlyingError.code) == BackendErrorCode.invalidSubscriberAttributes.rawValue + } + + func testUnknownResponseCreatesDefaultError() throws { + let result = try self.decode(Self.unknownResponse) + expect(result.code) == .unknownBackendError + expect(result.message).to(beNil()) + expect(result.attributeErrors).to(beEmpty()) + + let error = result + .asBackendError(with: .invalidRequest) as NSError + + expect(error.domain) == ErrorCode.errorDomain + expect(error.code) == ErrorCode.unknownBackendError.rawValue + expect(error.userInfo[ErrorDetails.finishableKey as String] as? Bool) == true + expect(error.userInfo[Backend.RCAttributeErrorsKey]).to(beNil()) + + let underlyingError = try XCTUnwrap(error.userInfo[NSUnderlyingErrorKey] as? NSError) + + expect(underlyingError.domain) == "RevenueCat.BackendErrorCode" + expect(underlyingError.code) == BackendErrorCode.unknownBackendError.rawValue + } + + func testErrorResponseWithOnlyMessage() throws { + let result = try self.decode(Self.onlyMessageError) + expect(result.code) == .unknownBackendError + expect(result.message) == "Something is wrong but we don't know what." + expect(result.attributeErrors).to(beEmpty()) + } + + func testErrorResponseWithUnknownErrorCode() throws { + let result = try self.decode(Self.unknownError) + + expect(result.code) == .unknownBackendError + expect(result.message) == "This is a future unknown error." + expect(result.attributeErrors).to(beEmpty()) + } + + func testErrorResponseWithIntegerErrorCode() throws { + let result = try self.decode(Self.integerCode) + + expect(result.code) == .invalidAuthToken + expect(result.message) == "Invalid auth token." + expect(result.attributeErrors).to(beEmpty()) + } + +} + +private extension ErrorResponseTests { + + static let unknownResponse = """ + { + "This is": "A different response format" + } + """ + + static let onlyMessageError = """ + { + "message": "Something is wrong but we don't know what." + } + """ + + static let withAttributeErrors = """ + { + "attribute_errors": [ + { + "key_name": "$email", + "message": "Email address is not a valid email." + } + ], + "code": "7263", + "message": "Some subscriber attributes keys were unable to be saved." + } + """ + + static let withAttributeErrorsInInvalidFormat = """ + { + "attribute_errors": [ + { + "invalid": "format" + } + ], + "code": "7263", + "message": "Some subscriber attributes keys were unable to be saved." + } + """ + + static let withoutAttributeErrors = """ + { + "code": "7224", + "message": "Invalid auth token." + } + """ + static let unknownError = """ + { + "code": "7301", + "message": "This is a future unknown error." + } + """ + static let integerCode = """ + { + "code": 7224, + "message": "Invalid auth token." + } + """ + +} + +private extension ErrorResponseTests { + + enum Error: Swift.Error { + + case unableToEncodeString + + } + + func decode(_ response: String) throws -> ErrorResponse { + guard let data = response.data(using: .utf8) else { + throw Error.unableToEncodeString + } + + return try JSONDecoder.default.decode(ErrorResponse.self, from: data) + } + +} diff --git a/Tests/UnitTests/Networking/HTTPStatusCodeTests.swift b/Tests/UnitTests/Networking/HTTPStatusCodeTests.swift index 10268ec1cc..801368b076 100644 --- a/Tests/UnitTests/Networking/HTTPStatusCodeTests.swift +++ b/Tests/UnitTests/Networking/HTTPStatusCodeTests.swift @@ -81,6 +81,24 @@ class HTTPStatusCodeTests: XCTestCase { expect(status(600).isServerError) == false } + func testIsSuccessfullySynced() { + expect(HTTPStatusCode.success.isSuccessfullySynced) == true + expect(HTTPStatusCode.createdSuccess.isSuccessfullySynced) == true + expect(HTTPStatusCode.redirect.isSuccessfullySynced) == true + expect(HTTPStatusCode.notModified.isSuccessfullySynced) == true + expect(HTTPStatusCode.invalidRequest.isSuccessfullySynced) == true + expect(status(100).isSuccessfullySynced) == true + expect(status(202).isSuccessfullySynced) == true + expect(status(226).isSuccessfullySynced) == true + expect(status(299).isSuccessfullySynced) == true + } + + func testIsNotSuccessfullySynced() { + expect(HTTPStatusCode.internalServerError.isSuccessfullySynced) == false + expect(HTTPStatusCode.networkConnectTimeoutError.isSuccessfullySynced) == false + expect(HTTPStatusCode.notFoundError.isSuccessfullySynced) == false + } + } private func status(_ code: Int) -> HTTPStatusCode {