diff --git a/Bucketeer/Sources/Internal/Remote/ApiClientImpl.swift b/Bucketeer/Sources/Internal/Remote/ApiClientImpl.swift index 3250090..5435268 100644 --- a/Bucketeer/Sources/Internal/Remote/ApiClientImpl.swift +++ b/Bucketeer/Sources/Internal/Remote/ApiClientImpl.swift @@ -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)) } } @@ -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?) } diff --git a/Bucketeer/Sources/Public/BKTError.swift b/Bucketeer/Sources/Public/BKTError.swift index 6ac5df3..fefc52c 100644 --- a/Bucketeer/Sources/Public/BKTError.swift +++ b/Bucketeer/Sources/Public/BKTError.swift @@ -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 } diff --git a/BucketeerTests/ApiClientTests.swift b/BucketeerTests/ApiClientTests.swift index 150dab8..ade7e75 100644 --- a/BucketeerTests/ApiClientTests.swift +++ b/BucketeerTests/ApiClientTests.swift @@ -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 @@ -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 diff --git a/BucketeerTests/BKTErrorTests.swift b/BucketeerTests/BKTErrorTests.swift index f2bebeb..dc867ec 100644 --- a/BucketeerTests/BKTErrorTests.swift +++ b/BucketeerTests/BKTErrorTests.swift @@ -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)