Skip to content

Commit

Permalink
Created ErrorResponse to abstract error deserialization
Browse files Browse the repository at this point in the history
This is duplicated in most of the `NetworkOperation`s. This new shared implementation will be used by `HTTPClient`.
  • Loading branch information
NachoSoto committed Apr 1, 2022
1 parent 7b42026 commit 37d47e9
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 4 deletions.
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -676,6 +677,7 @@
57C381D92796153D009E3940 /* SK1StoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK1StoreProductDiscount.swift; sourceTree = "<group>"; };
57C381DB27961547009E3940 /* SK2StoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2StoreProductDiscount.swift; sourceTree = "<group>"; };
57C381E1279627B7009E3940 /* MockStoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreProductDiscount.swift; sourceTree = "<group>"; };
57D04BB727D947C6006DAC06 /* HTTPResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponseTests.swift; sourceTree = "<group>"; };
57DC9F4527CC2E4900DA6AF9 /* HTTPRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequest.swift; sourceTree = "<group>"; };
57DC9F4927CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatusCodeTests.swift; sourceTree = "<group>"; };
57E0474B27729A1E0082FE91 /* __Snapshots__ */ = {isa = PBXFileReference; lastKnownFileType = folder; path = __Snapshots__; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1400,6 +1402,7 @@
B380D69A27726AB500984578 /* DNSCheckerTests.swift */,
576C8AD827D2BCB90058FA6E /* HTTPRequestTests.swift */,
57DC9F4927CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift */,
57D04BB727D947C6006DAC06 /* HTTPResponseTests.swift */,
);
path = Networking;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
67 changes: 67 additions & 0 deletions Sources/Networking/HTTPResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttributeError>.self,
forKey: .attributeErrors)
) ?? []

self.attributeErrors = attributeErrors
.dictionaryAllowingDuplicateKeys { $0.keyName }
.mapValues { $0.message }
}

}
4 changes: 4 additions & 0 deletions Sources/Networking/HTTPStatusCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,8 @@ extension HTTPStatusCode {
return 500...599 ~= self.rawValue
}

var isSuccessfullySynced: Bool {
return !(self.isServerError || self == .notFoundError)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
227 changes: 227 additions & 0 deletions Tests/UnitTests/Networking/HTTPResponseTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}

}
18 changes: 18 additions & 0 deletions Tests/UnitTests/Networking/HTTPStatusCodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit 37d47e9

Please sign in to comment.