Skip to content

Commit

Permalink
fix: unknown error when handle invalid JSON responses (#77)
Browse files Browse the repository at this point in the history
  • Loading branch information
duyhungtnn committed Mar 22, 2024
1 parent 4dee135 commit ff7cec6
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 10 deletions.
28 changes: 19 additions & 9 deletions Bucketeer/Sources/Internal/Remote/ApiClientImpl.swift
Expand Up @@ -144,26 +144,35 @@ final class ApiClientImpl: ApiClient {
// `result` shared resource between the network queue and the SDK queue
var result : Result<(Response, URLResponse), Error>?
let responseParser : (Data?, URLResponse?, Error?) -> Result<(Response, URLResponse), Error> = { data, urlResponse, error in
guard let data = data else {
guard let urlResponse = urlResponse as? HTTPURLResponse else {
// error is not from server
guard let error = error else {
return .failure(ResponseError.unknown(urlResponse))
}
return .failure(error)
}

guard let urlResponse = urlResponse as? HTTPURLResponse else {
return .failure(ResponseError.unknown(urlResponse))
let statusCode = urlResponse.statusCode
guard 200..<300 ~= statusCode else {
// UnacceptableCode
let response: ErrorResponse?
if let data = data {
response = try? JSONDecoder().decode(ErrorResponse.self, from: data)
} else {
response = nil
}
let error = ResponseError.unacceptableCode(code: statusCode, response: response)
return .failure(error)
}
// Success code
guard let data = data else {
return .failure(ResponseError.invalidJSONResponse(code: statusCode, error: nil))
}
do {
guard 200..<300 ~= urlResponse.statusCode else {
let response: ErrorResponse? = try? JSONDecoder().decode(ErrorResponse.self, from: data)
let error = ResponseError.unacceptableCode(code: urlResponse.statusCode, response: response)
return .failure(error)
}
let response = try JSONDecoder().decode(Response.self, from: data)
return .success((response, urlResponse))
} catch let error {
return .failure(error)
return .failure(ResponseError.invalidJSONResponse(code: statusCode, error: error))
}
}

Expand Down Expand Up @@ -204,6 +213,7 @@ final class ApiClientImpl: ApiClient {
}

enum ResponseError: Error {
case invalidJSONResponse(code: Int, error: Error?)
case unknown(URLResponse?)
case unacceptableCode(code: Int, response: ErrorResponse?)
}
Expand Down
3 changes: 3 additions & 0 deletions Bucketeer/Sources/Public/BKTError.swift
Expand Up @@ -101,6 +101,9 @@ extension BKTError : LocalizedError {
message = "[\(urlResponse.statusCode)] \(urlResponse)"
}
self = .network(message: "Network connection error: \(message)", error: error)
case .invalidJSONResponse(let code, _):
let message: String = "invaild JSON response for status \(code)"
self = .unknownServer(message: "Unknown server error: \(message)", error: error, statusCode: code)
}
return
}
Expand Down
187 changes: 186 additions & 1 deletion BucketeerTests/ApiClientTests.swift
Expand Up @@ -650,7 +650,7 @@ class ApiClientTests: XCTestCase {
path: path,
timeoutMillis: 100) { (result: Result<(MockResponse, URLResponse), Error>) in
switch result {
case .success((let response, _)):
case .success((_, _)):
XCTFail("should not success")
case .failure(let error):
guard
Expand Down Expand Up @@ -1148,5 +1148,190 @@ class ApiClientTests: XCTestCase {
}
wait(for: [expectation], timeout: 0.1)
}

func testTaskFailWithUnacceptableCode() throws {
let mockDataReponse = try JSONEncoder().encode(MockResponse())
let cases = [
ResponseCase(statusCode:300, bodyResponse: Data("".utf8), name: "Case: empty string"),
ResponseCase(statusCode:300, bodyResponse: Data("okay".utf8), name: "Case: random string"),
ResponseCase(statusCode:300, bodyResponse: nil, name: "Case: nil"),
ResponseCase(statusCode:300, bodyResponse: mockDataReponse, name: "Case: vaild JSON"),

ResponseCase(statusCode:400, bodyResponse: Data("".utf8), name: "Case: empty string"),
ResponseCase(statusCode:400, bodyResponse: Data("okay".utf8), name: "Case: random string"),
ResponseCase(statusCode:400, bodyResponse: nil, name: "Case: nil"),
ResponseCase(statusCode:400, bodyResponse: mockDataReponse, name: "Case: vaild JSON"),

ResponseCase(statusCode:500, bodyResponse: Data("".utf8), name: "Case: empty string"),
ResponseCase(statusCode:500, bodyResponse: Data("okay".utf8), name: "Case: random string"),
ResponseCase(statusCode:500, bodyResponse: nil, name: "Case: nil"),
ResponseCase(statusCode:500, bodyResponse: mockDataReponse, name: "Case: vaild JSON"),

ResponseCase(statusCode:499, bodyResponse: Data("".utf8), name: "Case: empty string for the unknown server error"),
ResponseCase(statusCode:499, bodyResponse: Data("okay".utf8), name: "Case: random string for the unknown server error"),
ResponseCase(statusCode:499, bodyResponse: nil, name: "Case: nil for the unknown server error"),
ResponseCase(statusCode:499, bodyResponse: mockDataReponse, name: "Case: vaild JSON for the unknown server error")
]

var expectations = [XCTestExpectation]()
for testCase in cases {
let expectation = XCTestExpectation(description: testCase.name)
expectation.expectedFulfillmentCount = 2

let mockRequestBody = MockRequestBody()
let data = testCase.bodyResponse

let apiEndpointURL = URL(string: "https://test.bucketeer.io")!
let path = "path"
let apiKey = "x:api-key"

let session = MockSession(
configuration: .default,
requestHandler: { request in
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.host, apiEndpointURL.host)
XCTAssertEqual(request.url?.path, "/\(path)")
XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], apiKey)
XCTAssertEqual(request.timeoutInterval, 0.1)
let data = request.httpBody ?? Data()
let jsonString = String(data: data, encoding: .utf8) ?? ""
let expected = """
{
"value" : "body"
}
"""
XCTAssertEqual(jsonString, expected)
expectation.fulfill()
},
data: data,
response: HTTPURLResponse(
url: apiEndpointURL.appendingPathComponent(path),
statusCode: testCase.statusCode,
httpVersion: nil,
headerFields: nil
),
error: nil
)
let api = ApiClientImpl(
apiEndpoint: apiEndpointURL,
apiKey: apiKey,
featureTag: "tag1",
defaultRequestTimeoutMills: 200,
session: session,
logger: nil
)
api.send(
requestBody: mockRequestBody,
path: path,
timeoutMillis: 100) { (result: Result<(MockResponse, URLResponse), Error>) in
switch result {
case .success((_, _)):
XCTFail("should not success")
case .failure(let error):
guard
let error = error as? ResponseError,
case .unacceptableCode(let code, _) = error, code == testCase.statusCode else {
XCTFail("code should be \(testCase.statusCode) for case: \(testCase.name)")
return
}
}
expectation.fulfill()
}
expectations.append(expectation)
}
wait(for: expectations, timeout: 10)
}

func testTaskSuccessWithAcceptableCode() throws {
let mockDataReponse = try JSONEncoder().encode(MockResponse())
let cases = [
ResponseCase(statusCode:200, bodyResponse: Data("".utf8), name: "Case: empty string"),
ResponseCase(statusCode:200, bodyResponse: Data("okay".utf8), name: "Case: random string"),
ResponseCase(statusCode:200, bodyResponse: nil, name: "Case: nil"),
ResponseCase(statusCode:200, bodyResponse: mockDataReponse, name: "Case: vaild JSON", shouldSuccess: true),

ResponseCase(statusCode:201, bodyResponse: Data("".utf8), name: "Case: empty string"),
ResponseCase(statusCode:201, bodyResponse: Data("okay".utf8), name: "Case: random string"),
ResponseCase(statusCode:201, bodyResponse: nil, name: "Case: nil"),
ResponseCase(statusCode:201, bodyResponse: mockDataReponse, name: "Case: vaild JSON", shouldSuccess: true),

ResponseCase(statusCode:204, bodyResponse: Data("".utf8), name: "Case: empty string"),
ResponseCase(statusCode:204, bodyResponse: Data("okay".utf8), name: "Case: random string"),
ResponseCase(statusCode:204, bodyResponse: nil, name: "Case: nil"),
ResponseCase(statusCode:204, bodyResponse: mockDataReponse, name: "Case: vaild JSON", shouldSuccess: true)
]

var expectations = [XCTestExpectation]()
cases.forEach { testCase in
let expectation = XCTestExpectation(description: testCase.name)
expectation.expectedFulfillmentCount = 1

let mockRequestBody = MockRequestBody()
let data = testCase.bodyResponse

let apiEndpointURL = URL(string: "https://test.bucketeer.io")!
let path = "path"
let apiKey = "x:api-key"

let session = MockSession(
configuration: .default,
requestHandler: nil,
data: data,
response: HTTPURLResponse(
url: apiEndpointURL.appendingPathComponent(path),
statusCode: testCase.statusCode,
httpVersion: nil,
headerFields: nil
),
error: nil
)
let api = ApiClientImpl(
apiEndpoint: apiEndpointURL,
apiKey: apiKey,
featureTag: "tag1",
defaultRequestTimeoutMills: 200,
session: session,
logger: nil
)
api.send(
requestBody: mockRequestBody,
path: path,
timeoutMillis: 100) { (result: Result<(MockResponse, URLResponse), Error>) in
switch result {
case .success((_, _)):
if (!testCase.shouldSuccess) {
XCTFail("should not success - case: \(testCase.name) - status code \(testCase.statusCode)")
}
case .failure(let error):
guard
let error = error as? ResponseError,
case .invalidJSONResponse(let code, _) = error, code == testCase.statusCode else {
XCTFail("error should be .invalidJSONResponse, code should be \(testCase.statusCode) for case: \(testCase.name)")
return
}
if (testCase.shouldSuccess) {
XCTFail("should success - case: \(testCase.name) - status code \(testCase.statusCode) - error \(error)")
}
}
expectation.fulfill()
}
expectations.append(expectation)
}
wait(for: expectations, timeout: 10)
}
}

class ResponseCase {
internal init(statusCode: Int, bodyResponse: Data? = nil, name: String, shouldSuccess: Bool = false) {
self.statusCode = statusCode
self.bodyResponse = bodyResponse
self.name = name
self.shouldSuccess = shouldSuccess
}

let statusCode: Int
let bodyResponse: Data?
let name: String
let shouldSuccess: Bool
}
// swiftlint:enable type_body_length file_length
8 changes: 8 additions & 0 deletions BucketeerTests/BKTErrorTests.swift
Expand Up @@ -131,6 +131,14 @@ class BKTErrorTests: XCTestCase {
}

func testInitWithResponseError() {
assertEqual(
.init(error: ResponseError.invalidJSONResponse(code: 200, error: SomeError.a)),
.unknownServer(
message: "Unknown server error: invaild JSON response for status 200",
error: ResponseError.invalidJSONResponse(code: 200, error: SomeError.a),
statusCode: 200
)
)
assertEqual(
.init(error: ResponseError.unacceptableCode(code: 300, response: nil)),
.redirectRequest(message: "RedirectRequest error", statusCode: 300)
Expand Down

0 comments on commit ff7cec6

Please sign in to comment.