Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ _None._

### Breaking Changes

_None._
- Add a new `unacceptableStatusCode` error case to `WordPressAPIError`. [#668]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How timely. I left a comment on a PR recently that introduced breaking changes, wordpress-mobile/WordPress-iOS#22320 (comment), asking to make them non-breaking with a reminder to update them when we have enough of a corpus of breaking changes to warrant the "effort" of updating WordPressAuthenticator as well.

This seems to be the case now 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Followed up here: #678


### New Features

Expand Down
62 changes: 44 additions & 18 deletions WordPressKit/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extension URLSession {

func perform<E: LocalizedError>(
request builder: HTTPRequestBuilder,
acceptableStatusCodes: [ClosedRange<Int>] = [200...299],
errorType: E.Type = E.self
) async -> WordPressAPIResult<HTTPAPIResponse<Data>, E> {
guard let request = try? builder.build() else {
Expand All @@ -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<Data> {

func assessStatusCode<S, E: LocalizedError>(
acceptable: [ClosedRange<Int>] = [200...299],
success: (Success) -> S?,
failure: (Success) -> E?
) -> WordPressAPIResult<S, E> where Failure == WordPressAPIError<E> {
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<NewSuccess, E: LocalizedError>(
_ transform: (Success) -> NewSuccess?
) -> WordPressAPIResult<NewSuccess, E> where Success == HTTPAPIResponse<Data>, Failure == WordPressAPIError<E> {
flatMap { success in
guard let newSuccess = transform(success) else {
return .failure(.unparsableResponse(response: success.response, body: success.body))
}

return .success(newSuccess)
}
}

func decodeSuccess<NewSuccess: Decodable, E: LocalizedError>(
_ decoder: JSONDecoder = JSONDecoder()
) -> WordPressAPIResult<NewSuccess, E> where Success == HTTPAPIResponse<Data>, Failure == WordPressAPIError<E> {
mapSuccess {
try? decoder.decode(NewSuccess.self, from: $0.body)
}
}

func mapUnacceptableStatusCodeError<E: LocalizedError>(
_ transform: (HTTPURLResponse, Data) -> E?
) -> WordPressAPIResult<Success, E> where Failure == WordPressAPIError<E> {
mapError { error in
if case let .unacceptableStatusCode(response, body) = error {
if let endpointError = transform(response, body) {
return WordPressAPIError<E>.endpointError(endpointError)
} else {
return .failure(.unparsableResponse(response: response.response, body: response.body))
return WordPressAPIError<E>.unparsableResponse(response: response, body: body)
}
}
return error
}
}

func mapUnacceptableStatusCodeError<E>(
_ decoder: JSONDecoder = JSONDecoder()
) -> WordPressAPIResult<Success, E> where E: LocalizedError, E: Decodable, Failure == WordPressAPIError<E> {
mapUnacceptableStatusCodeError { _, body in
try? decoder.decode(E.self, from: body)
}
}

Expand Down
4 changes: 3 additions & 1 deletion WordPressKit/WordPressAPIError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public enum WordPressAPIError<EndpointError>: 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.
Expand All @@ -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):
Expand Down
104 changes: 44 additions & 60 deletions WordPressKitTests/Utilities/URLSessionHelperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,96 +35,80 @@ 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 {
stub(condition: isPath("/hello")) { _ in
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 {
stub(condition: isPath("/hello")) { _ in
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<TestError>.endpointError(.postNotFound)) = parsedResult {
if case .failure(WordPressAPIError<TestError>.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<Post, TestError> = 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
}