feature(networking): Pass response dictionary within error for JSON API

Refs: https://gitlab.protontech.ch/apple/shared/protoncore/-/merge_requests/1493
This commit is contained in:
Krzysztof Siejkowski 2023-10-27 17:09:51 +00:00
commit 58ca45d3a1
5 changed files with 140 additions and 3 deletions

View file

@ -56,16 +56,23 @@ extension ErrorResponse {
}
public extension NSError {
var responseDictionary: JSONDictionary? {
userInfo[ResponseError.responseDictionaryUserInfoKey] as? JSONDictionary
}
convenience init(_ serverError: ErrorResponse) {
self.init(domain: "ProtonCore-Networking", code: serverError.code,
localizedDescription: serverError.error,
localizedFailureReason: serverError.errorDescription)
}
convenience init(domain: String, code: Int,
convenience init(domain: String,
code: Int,
responseDictionary: JSONDictionary? = nil,
localizedDescription: String,
localizedFailureReason: String? = nil, localizedRecoverySuggestion: String? = nil) {
var userInfo = [NSLocalizedDescriptionKey: localizedDescription]
var userInfo: [String: Any] = [NSLocalizedDescriptionKey: localizedDescription]
if let localizedFailureReason = localizedFailureReason {
userInfo[NSLocalizedFailureReasonErrorKey] = localizedFailureReason
@ -74,6 +81,10 @@ public extension NSError {
if let localizedRecoverySuggestion = localizedRecoverySuggestion {
userInfo[NSLocalizedRecoverySuggestionErrorKey] = localizedRecoverySuggestion
}
if let responseDictionary {
userInfo[ResponseError.responseDictionaryUserInfoKey] = responseDictionary
}
self.init(domain: domain, code: code, userInfo: userInfo)
}

View file

@ -30,6 +30,8 @@ public enum ResponseErrorDomains: String {
}
public struct ResponseError: Error, Equatable {
public static let responseDictionaryUserInfoKey = "responseDictionaryUserInfoKey"
/// This is the http status code, like 200, 404, 500 etc. It will be nil if there was no http response,
/// for example in case of timeout
@ -44,20 +46,48 @@ public struct ResponseError: Error, Equatable {
public let userFacingMessage: String?
public let underlyingError: NSError?
public let responseDictionary: [String: Any]?
public var bestShotAtReasonableErrorCode: Int {
responseCode ?? httpCode ?? underlyingError?.code ?? (self as NSError).code
}
public init(httpCode: Int?, responseCode: Int?, userFacingMessage: String?, underlyingError: NSError?) {
if let responseDictionary = underlyingError?.responseDictionary {
let strippedUnderlyingError = underlyingError.map {
var userInfo = $0.userInfo
userInfo.removeValue(forKey: ResponseError.responseDictionaryUserInfoKey)
return NSError(domain: $0.domain, code: $0.code, userInfo: userInfo)
}
self.init(httpCode: httpCode,
responseCode: responseCode,
userFacingMessage: userFacingMessage,
responseDictionary: responseDictionary,
underlyingError: strippedUnderlyingError)
} else {
self.init(httpCode: httpCode,
responseCode: responseCode,
userFacingMessage: userFacingMessage,
responseDictionary: nil,
underlyingError: underlyingError)
}
}
private init(httpCode: Int?, responseCode: Int?, userFacingMessage: String?, responseDictionary: JSONDictionary?, underlyingError: NSError?) {
self.httpCode = httpCode
self.responseCode = responseCode
self.userFacingMessage = userFacingMessage
self.responseDictionary = responseDictionary
self.underlyingError = underlyingError
}
public func withUpdated(userFacingMessage: String) -> ResponseError {
ResponseError(httpCode: httpCode, responseCode: responseCode, userFacingMessage: userFacingMessage, underlyingError: underlyingError)
ResponseError(httpCode: httpCode,
responseCode: responseCode,
userFacingMessage: userFacingMessage,
responseDictionary: responseDictionary,
underlyingError: underlyingError)
}
public func withUpdated(underlyingError: LocalizedError) -> ResponseError {
@ -66,6 +96,23 @@ public struct ResponseError: Error, Equatable {
userFacingMessage: underlyingError.localizedDescription,
underlyingError: underlyingError as NSError)
}
public static func == (lhs: ResponseError, rhs: ResponseError) -> Bool {
// the response dictionaries are ignored
lhs.httpCode == rhs.httpCode &&
lhs.responseCode == rhs.responseCode &&
lhs.userFacingMessage == rhs.userFacingMessage &&
lhs.underlyingError == rhs.underlyingError
}
}
extension ResponseError: CustomStringConvertible, CustomDebugStringConvertible {
public var description: String {
errorDescription ?? ""
}
public var debugDescription: String {
errorDescription ?? ""
}
}
extension ResponseError: LocalizedError {

View file

@ -353,12 +353,14 @@ public class PMAPIService: APIService {
error = NSError(
domain: ResponseErrorDomains.withResponseCode.rawValue,
code: responseCode,
responseDictionary: dict,
localizedDescription: dict["Error"] as? String ?? ""
)
} else {
error = NSError(
domain: ResponseErrorDomains.withStatusCode.rawValue,
code: httpResponse.statusCode,
responseDictionary: dict,
localizedDescription: dict["Error"] as? String ?? ""
)
}

View file

@ -124,4 +124,57 @@ final class APIServiceErrorTests: XCTestCase {
XCTAssertEqual(error.code, 123)
XCTAssertEqual(error.localizedDescription, "test message")
}
func testResponseError_CarriesResponseDict() throws {
let originalResponseDict: [String: Any] = ["string": "test string", "number": 24]
let underlyingError = NSError(domain: "tests",
code: 42,
responseDictionary: originalResponseDict,
localizedDescription: "test localizedDescription")
let testError = ResponseError(httpCode: 200,
responseCode: 1000,
userFacingMessage: "test facing message",
underlyingError: underlyingError)
let passedResponseDict = try XCTUnwrap(testError.responseDictionary)
XCTAssertTrue((passedResponseDict as NSDictionary).isEqual(to: originalResponseDict))
}
func testResponseError_RemovesResponseDictFromUnderlyingError() throws {
let underlyingError = NSError(domain: "tests",
code: 42,
responseDictionary: ["string": "test string", "number": 24],
localizedDescription: "test localizedDescription")
let testError = ResponseError(httpCode: 200,
responseCode: 1000,
userFacingMessage: "test facing message",
underlyingError: underlyingError)
let passedUnderlyingError = try XCTUnwrap(testError.underlyingError)
XCTAssertNotNil(underlyingError.userInfo[ResponseError.responseDictionaryUserInfoKey])
XCTAssertNil(passedUnderlyingError.userInfo[ResponseError.responseDictionaryUserInfoKey])
}
func testResponseError_DoesNotIncludeResponseDictWhenPrinted() throws {
let testString = "test string"
let testNumber = 24
let underlyingError = NSError(domain: "tests",
code: 42,
responseDictionary: ["string": "test string", "number": 24],
localizedDescription: "test localizedDescription")
let testError = ResponseError(httpCode: 200,
responseCode: 1000,
userFacingMessage: nil,
underlyingError: underlyingError)
let interpolatedError = "\(testError)"
let errorDescription = testError.localizedDescription
let errorUserFacingMessage = testError.userFacingMessage ?? ""
let stringDescribingError = String(describing: testError)
XCTAssertFalse(interpolatedError.contains(testString))
XCTAssertFalse(interpolatedError.contains("\(testNumber)"))
XCTAssertFalse(errorDescription.contains(testString))
XCTAssertFalse(errorDescription.contains("\(testNumber)"))
XCTAssertFalse(errorUserFacingMessage.contains(testString))
XCTAssertFalse(errorUserFacingMessage.contains("\(testNumber)"))
XCTAssertFalse(stringDescribingError.contains(testString))
XCTAssertFalse(stringDescribingError.contains("\(testNumber)"))
}
}

View file

@ -156,6 +156,30 @@ final class PMAPIServiceRequestTests: XCTestCase {
XCTAssertEqual(error.code, 404)
}
func testRequestWithJSONReturnsResponseDictionaryInError() async throws {
let originalResponseDict: [String: Any] = ["Code": 4242, "string": "test string", "number": 24]
let service = testService
sessionMock.generateStub.bodyIs { _, method, url, params, time, retryPolicy in
SessionRequest(parameters: params, urlString: url, method: method, timeout: time ?? 30.0, retryPolicy: retryPolicy)
}
sessionMock.requestJSONStub.bodyIs { _, _, _, completion in
completion(
URLSessionDataTaskMock(response: HTTPURLResponse(statusCode: 404)),
.success(originalResponseDict)
)
}
let result = await withCheckedContinuation { continuation in
service.request(method: .get, path: "/unit/tests", parameters: nil, headers: nil, authenticated: false, authRetry: true,
customAuthCredential: nil, nonDefaultTimeout: nil, retryPolicy: .userInitiated, jsonCompletion: optionalContinuation(continuation))
}
let error = try XCTUnwrap(result.error)
let responseDictionary = try XCTUnwrap(error.responseDictionary)
XCTAssertTrue((responseDictionary as NSDictionary).isEqual(to: originalResponseDict))
}
// MARK: - Deprecated API
@available(*, deprecated, message: "testing deprecated api")