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
8 changes: 8 additions & 0 deletions WordPressKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
46ABD0EA262EEE0400C7FF24 /* AppTransportSecuritySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ABD0E9262EEE0400C7FF24 /* AppTransportSecuritySettingsTests.swift */; };
4A11239A2B19269A004690CF /* WordPressAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1123992B19269A004690CF /* WordPressAPIError.swift */; };
4A11239C2B1926B7004690CF /* HTTPRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A11239B2B1926B7004690CF /* HTTPRequestBuilder.swift */; };
4A11239E2B1926D1004690CF /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A11239D2B1926D1004690CF /* HTTPClient.swift */; };
4A1DEF44293051BC00322608 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1DEF43293051BC00322608 /* LoggingTests.swift */; };
4A1DEF46293051C600322608 /* LoggingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4A1DEF45293051C600322608 /* LoggingTests.m */; };
4A68E3CD29404181004AC3DC /* RemoteBlog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3CC29404181004AC3DC /* RemoteBlog.swift */; };
Expand All @@ -152,6 +153,7 @@
4A68E3DF29407100004AC3DC /* RemoteReaderTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3DE29407100004AC3DC /* RemoteReaderTopic.swift */; };
4A68E3E1294076C1004AC3DC /* RemoteReaderSiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3E0294076C1004AC3DC /* RemoteReaderSiteInfo.swift */; };
4A6B4A842B26974F00802316 /* HTTPRequestBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A6B4A832B26974F00802316 /* HTTPRequestBuilderTests.swift */; };
4A6B4A862B269D0C00802316 /* URLSessionHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A6B4A852B269D0C00802316 /* URLSessionHelperTests.swift */; };
4AA5A1A32AA68F6B00969464 /* MediaLibraryTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA5A1A22AA68F6B00969464 /* MediaLibraryTestSupport.swift */; };
4AA5A1A52AA695D700969464 /* LoadMediaLibraryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA5A1A42AA695D700969464 /* LoadMediaLibraryTests.swift */; };
57BCD3D426209D9500292CB3 /* AppTransportSecuritySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BCD3D326209D9500292CB3 /* AppTransportSecuritySettings.swift */; };
Expand Down Expand Up @@ -835,6 +837,7 @@
46ABD0E9262EEE0400C7FF24 /* AppTransportSecuritySettingsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppTransportSecuritySettingsTests.swift; sourceTree = "<group>"; };
4A1123992B19269A004690CF /* WordPressAPIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressAPIError.swift; sourceTree = "<group>"; };
4A11239B2B1926B7004690CF /* HTTPRequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPRequestBuilder.swift; sourceTree = "<group>"; };
4A11239D2B1926D1004690CF /* HTTPClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
4A1DEF43293051BC00322608 /* LoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingTests.swift; sourceTree = "<group>"; };
4A1DEF45293051C600322608 /* LoggingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LoggingTests.m; sourceTree = "<group>"; };
4A68E3CC29404181004AC3DC /* RemoteBlog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteBlog.swift; sourceTree = "<group>"; };
Expand All @@ -846,6 +849,7 @@
4A68E3DE29407100004AC3DC /* RemoteReaderTopic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteReaderTopic.swift; sourceTree = "<group>"; };
4A68E3E0294076C1004AC3DC /* RemoteReaderSiteInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteReaderSiteInfo.swift; sourceTree = "<group>"; };
4A6B4A832B26974F00802316 /* HTTPRequestBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequestBuilderTests.swift; sourceTree = "<group>"; };
4A6B4A852B269D0C00802316 /* URLSessionHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHelperTests.swift; sourceTree = "<group>"; };
4AA5A1A22AA68F6B00969464 /* MediaLibraryTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryTestSupport.swift; sourceTree = "<group>"; };
4AA5A1A42AA695D700969464 /* LoadMediaLibraryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMediaLibraryTests.swift; sourceTree = "<group>"; };
57BCD3D326209D9500292CB3 /* AppTransportSecuritySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTransportSecuritySettings.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2424,6 +2428,7 @@
93BD27791EE73944002BB00B /* WordPressOrgXMLRPCApi.swift */,
93BD277A1EE73944002BB00B /* WordPressOrgXMLRPCValidator.swift */,
93BD277B1EE73944002BB00B /* WordPressRSDParser.swift */,
4A11239D2B1926D1004690CF /* HTTPClient.swift */,
4A11239B2B1926B7004690CF /* HTTPRequestBuilder.swift */,
4A1123992B19269A004690CF /* WordPressAPIError.swift */,
);
Expand Down Expand Up @@ -2632,6 +2637,7 @@
4A1DEF43293051BC00322608 /* LoggingTests.swift */,
4A1DEF45293051C600322608 /* LoggingTests.m */,
4A6B4A832B26974F00802316 /* HTTPRequestBuilderTests.swift */,
4A6B4A852B269D0C00802316 /* URLSessionHelperTests.swift */,
);
path = Utilities;
sourceTree = "<group>";
Expand Down Expand Up @@ -3235,6 +3241,7 @@
C76F456825B9F30E00BFEC87 /* JetpackScanHistory.swift in Sources */,
E1D6B556200E46F300325669 /* WPTimeZone.swift in Sources */,
93F50A3F1F227C8900B5BEBA /* UsersServiceRemoteXMLRPC.swift in Sources */,
4A11239E2B1926D1004690CF /* HTTPClient.swift in Sources */,
8C5734F925681A6A005E61EE /* Enum+UnknownCaseRepresentable.swift in Sources */,
9F3E0BA32087345F009CB5BA /* ReaderTopicServiceRemote+Subscription.swift in Sources */,
9F3E0B9E208733C3009CB5BA /* ReaderServiceDeliveryFrequency.swift in Sources */,
Expand Down Expand Up @@ -3429,6 +3436,7 @@
buildActionMask = 2147483647;
files = (
E1E89C6A1FD6BDB1006E7A33 /* PluginDirectoryTests.swift in Sources */,
4A6B4A862B269D0C00802316 /* URLSessionHelperTests.swift in Sources */,
9F3E0BAA20873773009CB5BA /* MockServiceRequest.swift in Sources */,
8B2F4BE524ABB3C70056C08A /* RemoteReaderPostTests.m in Sources */,
4AA5A1A32AA68F6B00969464 /* MediaLibraryTestSupport.swift in Sources */,
Expand Down
66 changes: 66 additions & 0 deletions WordPressKit/HTTPClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Foundation

public typealias WordPressAPIResult<Response, Error: LocalizedError> = Result<Response, WordPressAPIError<Error>>

struct HTTPAPIResponse<Body> {
var response: HTTPURLResponse
var body: Body
}

extension URLSession {

func perform<E: LocalizedError>(
request builder: HTTPRequestBuilder,
errorType: E.Type = E.self

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.

errorType looks unused?

image

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.

I'll keep rolling with the unused assumption and make an additional suggestion, which would be valid anyway but results in my opinion cleaner this way.

What do you think of renaming the method to a verb like "perform"? The fact that the method returns an API result is already encoded in the type signature.

This way, including the removed error type, we could go from a usage like:

let result = await URLSession.shared.apiResult(with: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self)

to:

let result = await URLSession.shared.perform(.init(url: URL(string: "https://wordpress.org/hello")!)

or, if I misunderstood the error type requirement,

let result = await URLSession.shared.perform(.init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self)

An alternative to perform could also be request

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.

errorType looks unused?

Yes. But that's intended. We don't really need it in the implementation. It's similar to JSONEncoder.encode where callers can use it to tell Swift compiler what the E type is instead of letting the compiler infers it based on the context. You can see examples of this usage in the unit tests.

What do you think of renaming the method to a verb like "perform"?

Sounds good to me! 👍

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.

[errorType works] similar to JSONEncoder.encode where callers can use it to tell Swift compiler what the E type is instead of letting the compiler infers it based on the context.

Gotcha. If we don't specify it, then we're left to explicitly set the type of the result we expect from the API call, as can be seen in this edit I made locally.

image

To be honest, I'd rather to that than pass unused arguments, but I can see how the current version is easier on the eyes. Also, as you point out, JSONEncoder and JSONDecoder set a precedent for this style, which might then result more familiar to Swift devs.

) async -> WordPressAPIResult<HTTPAPIResponse<Data>, E> {
guard let request = try? builder.build() else {
return .failure(.requestEncodingFailure)
}

let result: (Data, URLResponse)
do {
result = try await data(for: request)
} catch {
if let urlError = error as? URLError {
return .failure(.connection(urlError))
} else {
return .failure(.unknown(underlyingError: error))
}
}

let (body, response) = result

guard let response = response as? HTTPURLResponse else {
return .failure(.unparsableResponse(response: nil, 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))
} else {
return .failure(.unparsableResponse(response: response.response, body: response.body))
}
}
}
}

}
130 changes: 130 additions & 0 deletions WordPressKitTests/Utilities/URLSessionHelperTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Foundation
import XCTest
import OHHTTPStubs

@testable import WordPressKit

class URLSessionHelperTests: XCTestCase {

override func tearDown() {
super.tearDown()
HTTPStubs.removeAllStubs()
}

func testConnectionError() async throws {
stub(condition: isPath("/hello")) { _ in
HTTPStubsResponse(error: URLError(.serverCertificateUntrusted))
}

let result = await URLSession.shared.perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self)
do {
_ = try result.get()
XCTFail("The above call should throw")
} catch let WordPressAPIError<TestError>.connection(error) {
XCTAssertEqual(error.code, URLError.Code.serverCertificateUntrusted)
} catch {
XCTFail("Unknown error: \(error)")
}
}

func test200() async throws {
stub(condition: isPath("/hello")) { _ in
HTTPStubsResponse(data: "success".data(using: .utf8)!, statusCode: 200, 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 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])
Comment on lines +40 to +50

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.

Have you considered making the code synchronous?

Suggested change
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])
_ = result
.assessStatusCode { result in
XCTAssertEqual(String(data: result.body, encoding: .utf8), "success")
return result
} failure: { _ in
XCTFail("Expected result to be successful and this branch not to run")
return nil
}

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.

If neither closures of assessStatusCode is called, the updated test would pass. But the original test case would fail and catch the bug.

}

func testUnacceptable500() async throws {
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)

// The result is a successful result. This line should not throw
_ = try result.get()
Comment on lines +58 to +61

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.

I was surprised for a moment. "The result is a successful result," but the stub was configured to "fail" the request. The difference, however, is that the request succeeded to get a response and that the response contained a failure.

I wonder if there's a way to make this clearer from consumers. Or maybe I'm overthinking it...

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.

I had the same thought too. On one hand, what's done here follows URLSession's API, where it doesn't take HTTP semantic into account. On the other hand, I do want to make these helpers somewhat opinionated: locking them down to suit for our cases rather than making it too general like URLSession.

We can add an "acceptable status codes" argument to the perform(request:) function. That means we'll need to add another error case WordPressAPIError.unaccpetableStatusCode(HTTPAPIResponse<Data>). The helper that deals with success and error parsing (parsing generic JSON to specific model, parsing http error response to a concrete error type) will need to change too.

I think these changes may potentially be implemented later on. These helpers are internal API after they, they can be easily changed without much affects to others.

At the moment, I only looked into the code in WordPressOAuthClient.swift, and the helpers are all created based on the existing implementation. That's why I haven't put too much though into supporting Decodable, which I think is very likely going to happen once I start looking into the existing WP.com REST API implementation.


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])
}

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)

// 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])
}

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
}
await fulfillment(of: [expectation])
Comment on lines +104 to +118

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.

If I read this correctly based also on the info in the PR description, these tests are previews of how we'd use the code in real life to make API requests.

With that in mind, what do you think of using a name like map?

let session = ...

let result = await session.perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self)
   .map(
     acceptableStatusCodes: [200...299],
     success: { result in ... }
     failure: { result in ... }
  )

With default parameter:

let session = ...

let result = await session.perform(request: .init(url: URL(string: "https://wordpress.org/hello")!), errorType: TestError.self)
   .map(
     success: { result in ... }
     failure: { result in ... }
  )

I'm suggesting this because "assess" doesn't make me think of an operation that would apply the given transformations to self and return the result. 🤷‍♂️


if case .failure(WordPressAPIError<TestError>.endpointError(.postNotFound)) = parsedResult {
// DO nothing
} else {
XCTFail("Unexpected result: \(parsedResult)")
}
}
}

private enum TestError: LocalizedError {
case postNotFound
}