diff --git a/CHANGELOG.md b/CHANGELOG.md index 4527f75e..55269910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ _None._ ### Breaking Changes -_None._ +- Add a new `unacceptableStatusCode` error case to `WordPressAPIError`. [#668] ### New Features diff --git a/WordPressKit/HTTPClient.swift b/WordPressKit/HTTPClient.swift index ac72deb2..67c6b1b0 100644 --- a/WordPressKit/HTTPClient.swift +++ b/WordPressKit/HTTPClient.swift @@ -11,6 +11,7 @@ extension URLSession { func perform( request builder: HTTPRequestBuilder, + acceptableStatusCodes: [ClosedRange] = [200...299], errorType: E.Type = E.self ) async -> WordPressAPIResult, E> { guard let request = try? builder.build() else { @@ -34,32 +35,57 @@ extension URLSession { return .failure(.unparsableResponse(response: nil, body: body)) } + guard acceptableStatusCodes.contains(where: { $0 ~= response.statusCode }) else { + return .failure(.unacceptableStatusCode(response: response, body: body)) + } + return .success(.init(response: response, body: body)) } } -extension Result where Success == HTTPAPIResponse { - - func assessStatusCode( - acceptable: [ClosedRange] = [200...299], - success: (Success) -> S?, - failure: (Success) -> E? - ) -> WordPressAPIResult where Failure == WordPressAPIError { - flatMap { response in - if acceptable.contains(where: { $0 ~= response.response.statusCode }) { - if let result = success(response) { - return .success(result) - } else { - return .failure(.unparsableResponse(response: response.response, body: response.body)) - } - } else { - if let endpointError = failure(response) { - return .failure(.endpointError(endpointError)) +extension WordPressAPIResult { + + func mapSuccess( + _ transform: (Success) -> NewSuccess? + ) -> WordPressAPIResult where Success == HTTPAPIResponse, Failure == WordPressAPIError { + flatMap { success in + guard let newSuccess = transform(success) else { + return .failure(.unparsableResponse(response: success.response, body: success.body)) + } + + return .success(newSuccess) + } + } + + func decodeSuccess( + _ decoder: JSONDecoder = JSONDecoder() + ) -> WordPressAPIResult where Success == HTTPAPIResponse, Failure == WordPressAPIError { + mapSuccess { + try? decoder.decode(NewSuccess.self, from: $0.body) + } + } + + func mapUnacceptableStatusCodeError( + _ transform: (HTTPURLResponse, Data) -> E? + ) -> WordPressAPIResult where Failure == WordPressAPIError { + mapError { error in + if case let .unacceptableStatusCode(response, body) = error { + if let endpointError = transform(response, body) { + return WordPressAPIError.endpointError(endpointError) } else { - return .failure(.unparsableResponse(response: response.response, body: response.body)) + return WordPressAPIError.unparsableResponse(response: response, body: body) } } + return error + } + } + + func mapUnacceptableStatusCodeError( + _ decoder: JSONDecoder = JSONDecoder() + ) -> WordPressAPIResult where E: LocalizedError, E: Decodable, Failure == WordPressAPIError { + mapUnacceptableStatusCodeError { _, body in + try? decoder.decode(E.self, from: body) } } diff --git a/WordPressKit/WordPressAPIError.swift b/WordPressKit/WordPressAPIError.swift index 2e2518cd..0b8480de 100644 --- a/WordPressKit/WordPressAPIError.swift +++ b/WordPressKit/WordPressAPIError.swift @@ -15,6 +15,8 @@ public enum WordPressAPIError: Error where EndpointError: Localiz case connection(URLError) /// The API call returned an error result. For example, an OAuth endpoint may return an 'incorrect username or password' error, an upload media endpoint may return an 'unsupported media type' error. case endpointError(EndpointError) + /// The API call returned an status code that's unacceptable to the endpoint. + case unacceptableStatusCode(response: HTTPURLResponse, body: Data) /// The API call returned an HTTP response that WordPressKit can't parse. Receiving this error could be an indicator that there is an error response that's not handled properly by WordPressKit. case unparsableResponse(response: HTTPURLResponse?, body: Data?) /// Other error occured. @@ -30,7 +32,7 @@ extension WordPressAPIError: LocalizedError { // always returns a non-nil value. let localizedErrorMessage: String switch self { - case .requestEncodingFailure, .unparsableResponse: + case .requestEncodingFailure, .unparsableResponse, .unacceptableStatusCode: // These are usually programming errors. localizedErrorMessage = Self.unknownErrorMessage case let .endpointError(error): diff --git a/WordPressKitTests/Utilities/URLSessionHelperTests.swift b/WordPressKitTests/Utilities/URLSessionHelperTests.swift index 4fdc1be5..99e1819d 100644 --- a/WordPressKitTests/Utilities/URLSessionHelperTests.swift +++ b/WordPressKitTests/Utilities/URLSessionHelperTests.swift @@ -35,41 +35,25 @@ class URLSessionHelperTests: XCTestCase { let result = await URLSession.shared.perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self) // The result is a successful result. This line should not throw - _ = try result.get() - - let expectation = expectation(description: "API call returns a successful result") - _ = result - .assessStatusCode { result in - XCTAssertEqual(String(data: result.body, encoding: .utf8), "success") - expectation.fulfill() - return result - } failure: { _ in - // Do nothing - return nil - } - await fulfillment(of: [expectation]) + let response = try result.get() + + XCTAssertEqual(String(data: response.body, encoding: .utf8), "success") } - func testUnacceptable500() async throws { + func testUnacceptable500() async { stub(condition: isPath("/hello")) { _ in HTTPStubsResponse(data: "Internal server error".data(using: .utf8)!, statusCode: 500, headers: nil) } - let result = await URLSession.shared.perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self) + let result = await URLSession.shared + .perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self) - // The result is a successful result. This line should not throw - _ = try result.get() - - let expectation = expectation(description: "API call returns server error") - _ = result - .assessStatusCode { result in - return result - } failure: { result in - XCTAssertEqual(String(data: result.body, encoding: .utf8), "Internal server error") - expectation.fulfill() - return nil - } - await fulfillment(of: [expectation]) + switch result { + case let .failure(.unacceptableStatusCode(response, _)): + XCTAssertEqual(response.statusCode, 500) + default: + XCTFail("Got an unexpected result: \(result)") + } } func testAcceptable404() async throws { @@ -77,21 +61,15 @@ class URLSessionHelperTests: XCTestCase { HTTPStubsResponse(data: "Not found".data(using: .utf8)!, statusCode: 404, headers: nil) } - let result = await URLSession.shared.perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self) + let result = await URLSession.shared + .perform( + request: .init(url: URL(string: "https://wordpress.org/hello")!), + acceptableStatusCodes: [200...299, 400...499], errorType: TestError.self + ) // The result is a successful result. This line should not throw - _ = try result.get() - - let expectation = expectation(description: "API call returns not found") - _ = result - .assessStatusCode(acceptable: [200...299, 400...499]) { result in - XCTAssertEqual(String(data: result.body, encoding: .utf8), "Not found") - expectation.fulfill() - return result - } failure: { result in - return nil - } - await fulfillment(of: [expectation]) + let response = try result.get() + XCTAssertEqual(String(data: response.body, encoding: .utf8), "Not found") } func testParseError() async throws { @@ -99,32 +77,38 @@ class URLSessionHelperTests: XCTestCase { HTTPStubsResponse(data: "Not found".data(using: .utf8)!, statusCode: 404, headers: nil) } - let result = await URLSession.shared.perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self) - - // The result is a successful result. This line should not throw - _ = try result.get() - - let expectation = expectation(description: "API call returns not found") - let parsedResult = result - .assessStatusCode { result in - return result - } failure: { result in - expectation.fulfill() - if result.response.statusCode == 404 { - return .postNotFound - } - return nil + let result = await URLSession.shared + .perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self) + .mapUnacceptableStatusCodeError { response, _ in + XCTAssertEqual(response.statusCode, 404) + return .postNotFound } - await fulfillment(of: [expectation]) - if case .failure(WordPressAPIError.endpointError(.postNotFound)) = parsedResult { + if case .failure(WordPressAPIError.endpointError(.postNotFound)) = result { // DO nothing } else { - XCTFail("Unexpected result: \(parsedResult)") + XCTFail("Unexpected result: \(result)") + } + } + + func testParseSuccessAsJSON() async throws { + stub(condition: isPath("/hello")) { _ in + HTTPStubsResponse(jsonObject: ["title": "Hello Post"], statusCode: 200, headers: nil) } + + struct Post: Decodable { + var title: String + } + + let result: WordPressAPIResult = await URLSession.shared + .perform(request: .init(url: URL(string: "https://wordpress.org/hello")!)) + .decodeSuccess() + + try XCTAssertEqual(result.get().title, "Hello Post") } } -private enum TestError: LocalizedError { +private enum TestError: LocalizedError, Equatable { case postNotFound + case serverFailure }