Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit b4ffba5

Browse files
authored
Support multipart form in URLSession helper (#699)
2 parents e9afb31 + e6e52f1 commit b4ffba5

File tree

8 files changed

+161
-20
lines changed

8 files changed

+161
-20
lines changed

WordPressKit.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@
159159
4A57A6832B54A326008D0660 /* WordPressAPIError+NSErrorBrdige.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A57A6822B54A326008D0660 /* WordPressAPIError+NSErrorBrdige.swift */; };
160160
4A57A6872B54C68C008D0660 /* Constants.h in Headers */ = {isa = PBXBuildFile; fileRef = 4A57A6852B54C68C008D0660 /* Constants.h */; settings = {ATTRIBUTES = (Public, ); }; };
161161
4A57A6882B54C68C008D0660 /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = 4A57A6862B54C68C008D0660 /* Constants.m */; };
162+
4A5BC1A82B59DE6600C7D037 /* Either.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A5BC1A72B59DE6600C7D037 /* Either.swift */; };
162163
4A68E3CD29404181004AC3DC /* RemoteBlog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3CC29404181004AC3DC /* RemoteBlog.swift */; };
163164
4A68E3CF29404289004AC3DC /* RemoteBlogOptionsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3CE29404289004AC3DC /* RemoteBlogOptionsHelper.swift */; };
164165
4A68E3D329406AA0004AC3DC /* RemoteMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3D029406AA0004AC3DC /* RemoteMenu.swift */; };
@@ -873,6 +874,7 @@
873874
4A57A6822B54A326008D0660 /* WordPressAPIError+NSErrorBrdige.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WordPressAPIError+NSErrorBrdige.swift"; sourceTree = "<group>"; };
874875
4A57A6852B54C68C008D0660 /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = "<group>"; };
875876
4A57A6862B54C68C008D0660 /* Constants.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Constants.m; sourceTree = "<group>"; };
877+
4A5BC1A72B59DE6600C7D037 /* Either.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Either.swift; sourceTree = "<group>"; };
876878
4A68E3CC29404181004AC3DC /* RemoteBlog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteBlog.swift; sourceTree = "<group>"; };
877879
4A68E3CE29404289004AC3DC /* RemoteBlogOptionsHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteBlogOptionsHelper.swift; sourceTree = "<group>"; };
878880
4A68E3D029406AA0004AC3DC /* RemoteMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteMenu.swift; sourceTree = "<group>"; };
@@ -2494,6 +2496,7 @@
24942496
4A11239F2B196821004690CF /* MultipartForm.swift */,
24952497
4AE278432B2FAF6200E4D9B1 /* HTTPProtocolHelpers.swift */,
24962498
3F391E192B50F3EB007975C4 /* Result+Callback.swift */,
2499+
4A5BC1A72B59DE6600C7D037 /* Either.swift */,
24972500
4A57A6852B54C68C008D0660 /* Constants.h */,
24982501
4A57A6862B54C68C008D0660 /* Constants.m */,
24992502
);
@@ -3483,6 +3486,7 @@
34833486
436D5641211B7F4400CEAA33 /* DomainContactInformation.swift in Sources */,
34843487
FA28A3D6259079960082C7B0 /* JetpackRestoreTypes.swift in Sources */,
34853488
32FC20CE255DCC6100CD0A7B /* JetpackScanThreat.swift in Sources */,
3489+
4A5BC1A82B59DE6600C7D037 /* Either.swift in Sources */,
34863490
FE50965F2A2E42A500DDD071 /* JetpackSocialServiceRemote.swift in Sources */,
34873491
3F3195AD266FF94B00397EE7 /* ZendeskMetadata.swift in Sources */,
34883492
40A71C6E220E1D8E002E3D25 /* StatsServiceRemoteV2.swift in Sources */,

WordPressKit/Either.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Foundation
2+
3+
enum Either<L, R> {
4+
case left(L)
5+
case right(R)
6+
7+
func map<T>(left: (L) -> T, right: (R) -> T) -> T {
8+
switch self {
9+
case let .left(value):
10+
return left(value)
11+
case let .right(value):
12+
return right(value)
13+
}
14+
}
15+
}

WordPressKit/HTTPClient.swift

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,8 @@ extension URLSession {
5252
assert(parentProgress.cancellationHandler == nil, "The progress instance's cancellationHandler property must be nil")
5353
}
5454

55-
let request: URLRequest
56-
do {
57-
request = try builder.build()
58-
} catch {
59-
return .failure(.requestEncodingFailure(underlyingError: error))
60-
}
61-
6255
return await withCheckedContinuation { continuation in
63-
let task = dataTask(with: request) { data, response, error in
56+
let completion: @Sendable (Data?, URLResponse?, Error?) -> Void = { data, response, error in
6457
let result: WordPressAPIResult<HTTPAPIResponse<Data>, E> = Self.parseResponse(
6558
data: data,
6659
response: response,
@@ -70,6 +63,16 @@ extension URLSession {
7063

7164
continuation.resume(returning: result)
7265
}
66+
67+
let task: URLSessionTask
68+
69+
do {
70+
task = try self.task(for: builder, completion: completion)
71+
} catch {
72+
continuation.resume(returning: .failure(.requestEncodingFailure(underlyingError: error)))
73+
return
74+
}
75+
7376
task.resume()
7477

7578
if let parentProgress, parentProgress.totalUnitCount > parentProgress.completedUnitCount {
@@ -83,6 +86,32 @@ extension URLSession {
8386
}
8487
}
8588

89+
private func task(
90+
for builder: HTTPRequestBuilder,
91+
completion: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void
92+
) throws -> URLSessionTask {
93+
var request = try builder.build(encodeMultipartForm: false)
94+
95+
// Use special `URLSession.uploadTask` API for multipart POST requests.
96+
if let multipart = builder.multipartForm, !multipart.isEmpty {
97+
let isBackgroundSession = configuration.identifier != nil
98+
99+
return try builder
100+
.encodeMultipartForm(request: &request, forceWriteToFile: isBackgroundSession)
101+
.map(
102+
left: {
103+
uploadTask(with: request, from: $0, completionHandler: completion)
104+
},
105+
right: {
106+
uploadTask(with: request, fromFile: $0, completionHandler: completion)
107+
}
108+
)
109+
} else {
110+
// Use `URLSession.dataTask` for all other request
111+
return dataTask(with: request, completionHandler: completion)
112+
}
113+
}
114+
86115
private static func parseResponse<E: LocalizedError>(
87116
data: Data?,
88117
response: URLResponse?,

WordPressKit/HTTPRequestBuilder.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ final class HTTPRequestBuilder {
1717
private var method: Method = .get
1818
private var headers: [String: String] = [:]
1919
private var bodyBuilder: ((inout URLRequest) throws -> Void)?
20+
private(set) var multipartForm: [MultipartFormField]?
2021

2122
init(url: URL) {
2223
assert(url.scheme == "http" || url.scheme == "https")
@@ -83,6 +84,12 @@ final class HTTPRequestBuilder {
8384
return self
8485
}
8586

87+
func body(form: [MultipartFormField]) -> Self {
88+
// Unlike other similar functions, multipart form encoding is handled by the `build` function.
89+
multipartForm = form
90+
return self
91+
}
92+
8693
func body(json: Encodable, jsonEncoder: JSONEncoder = JSONEncoder()) -> Self {
8794
body(json: {
8895
try jsonEncoder.encode(json)
@@ -111,7 +118,7 @@ final class HTTPRequestBuilder {
111118
return self
112119
}
113120

114-
func build() throws -> URLRequest {
121+
func build(encodeMultipartForm: Bool = false) throws -> URLRequest {
115122
guard let url = urlComponents.url else {
116123
throw URLError(.badURL)
117124
}
@@ -123,13 +130,35 @@ final class HTTPRequestBuilder {
123130
request.addValue(value, forHTTPHeaderField: header)
124131
}
125132

133+
if encodeMultipartForm {
134+
let encoded = try self.encodeMultipartForm(request: &request, forceWriteToFile: false)
135+
switch encoded {
136+
case let .left(data):
137+
request.httpBody = data
138+
case let .right(url):
139+
request.httpBodyStream = InputStream(url: url)
140+
}
141+
}
142+
126143
if let bodyBuilder {
127144
assert(method.allowsHTTPBody, "Can't include body in HTTP \(method.rawValue) requests")
128145
try bodyBuilder(&request)
129146
}
130147

131148
return request
132149
}
150+
151+
func encodeMultipartForm(request: inout URLRequest, forceWriteToFile: Bool) throws -> Either<Data, URL> {
152+
guard let multipartForm, !multipartForm.isEmpty else {
153+
return .left(Data())
154+
}
155+
156+
let boundery = String(format: "wordpresskit.%08x", Int.random(in: Int.min..<Int.max))
157+
request.setValue("multipart/form-data; boundary=\(boundery)", forHTTPHeaderField: "Content-Type")
158+
return try multipartForm
159+
.multipartFormDataStream(boundary: boundery, forceWriteToFile: forceWriteToFile)
160+
161+
}
133162
}
134163

135164
extension HTTPRequestBuilder {

WordPressKit/MultipartForm.swift

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ struct MultipartFormField {
4040
}
4141

4242
extension Array where Element == MultipartFormField {
43-
private func multipartFormDestination(forceWriteToFile: Bool = false) throws -> (outputStream: OutputStream, tempFilePath: String?) {
43+
private func multipartFormDestination(forceWriteToFile: Bool) throws -> (outputStream: OutputStream, tempFilePath: String?) {
4444
let dest: OutputStream
4545
let tempFilePath: String?
4646

@@ -62,12 +62,12 @@ extension Array where Element == MultipartFormField {
6262
return (dest, tempFilePath)
6363
}
6464

65-
func multipartFormDataStream(boundary: String = String(format: "wordpresskit.%08x", Int.random(in: Int.min..<Int.max))) throws -> InputStream {
65+
func multipartFormDataStream(boundary: String, forceWriteToFile: Bool = false) throws -> Either<Data, URL> {
6666
guard !isEmpty else {
67-
return InputStream(data: Data())
67+
return .left(Data())
6868
}
6969

70-
let (dest, tempFilePath) = try multipartFormDestination()
70+
let (dest, tempFilePath) = try multipartFormDestination(forceWriteToFile: forceWriteToFile)
7171

7272
// Build the form content
7373
do {
@@ -79,14 +79,11 @@ extension Array where Element == MultipartFormField {
7979

8080
// Return the result as `InputStream`
8181
if let tempFilePath {
82-
guard let stream = InputStream(fileAtPath: tempFilePath) else {
83-
throw MultipartFormError.inaccessbileFile(path: tempFilePath)
84-
}
85-
return stream
82+
return .right(URL(fileURLWithPath: tempFilePath))
8683
}
8784

8885
if let data = dest.property(forKey: .dataWrittenToMemoryStreamKey) as? Data {
89-
return InputStream(data: data)
86+
return .left(data)
9087
}
9188

9289
throw MultipartFormError.impossible

WordPressKitTests/Utilities/HTTPRequestBuilderTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,32 @@ class HTTPRequestBuilderTests: XCTestCase {
225225
XCTAssertEqual(form, decodedForm)
226226
}
227227

228+
func testMultipartForm() throws {
229+
XCTAssertNotNil(
230+
try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!)
231+
.method(.post)
232+
.body(form: [MultipartFormField(text: "123456", name: "site")])
233+
.build(encodeMultipartForm: true)
234+
.httpBody
235+
)
236+
237+
XCTAssertNil(
238+
try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!)
239+
.method(.post)
240+
.body(form: [MultipartFormField(text: "123456", name: "site")])
241+
.build(encodeMultipartForm: false)
242+
.httpBody
243+
)
244+
245+
XCTAssertNil(
246+
try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!)
247+
.method(.post)
248+
.body(form: [MultipartFormField(text: "123456", name: "site")])
249+
.build(encodeMultipartForm: false)
250+
.httpBodyStream
251+
)
252+
}
253+
228254
}
229255

230256
private extension URLRequest {

WordPressKitTests/Utilities/MultipartFormTests.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class MutliparFormDataTests: XCTestCase {
8181
}
8282

8383
func testEmptyForm() throws {
84-
let formData = try [].multipartFormDataStream().readToEnd()
84+
let formData = try [].multipartFormDataStream(boundary: "test").readToEnd()
8585
XCTAssertTrue(formData.isEmpty)
8686
}
8787

@@ -141,7 +141,16 @@ class MutliparFormDataTests: XCTestCase {
141141

142142
}
143143

144-
private extension InputStream {
144+
extension Either<Data, URL> {
145+
func readToEnd() -> Data {
146+
map(
147+
left: { $0 },
148+
right: { InputStream(url: $0)?.readToEnd() ?? Data() }
149+
)
150+
}
151+
}
152+
153+
extension InputStream {
145154
func readToEnd() -> Data {
146155
open()
147156
defer { close() }

WordPressKitTests/Utilities/URLSessionHelperTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,38 @@ class URLSessionHelperTests: XCTestCase {
177177
XCTFail("Unexpected result: \(result)")
178178
}
179179
}
180+
181+
func testMultipartForm() async throws {
182+
var req: URLRequest?
183+
stub(condition: isPath("/hello")) {
184+
req = $0
185+
return HTTPStubsResponse(data: "success".data(using: .utf8)!, statusCode: 200, headers: nil)
186+
}
187+
188+
let builder = HTTPRequestBuilder(url: URL(string: "https://wordpress.org/hello")!)
189+
.method(.post)
190+
.body(form: [MultipartFormField(text: "value", name: "name", filename: nil)])
191+
192+
let _ = await URLSession.shared.perform(request: builder, errorType: TestError.self)
193+
194+
let request = try XCTUnwrap(req)
195+
let boundary = try XCTUnwrap(
196+
request
197+
.value(forHTTPHeaderField: "Content-Type")?.split(separator: ";")
198+
.map { $0.trimmingCharacters(in: .whitespaces) }
199+
.reduce(into: [String: String]()) {
200+
let pair = $1.split(separator: "=")
201+
if pair.count == 2 {
202+
$0[String(pair[0])] = String(pair[1])
203+
}
204+
}["boundary"]
205+
)
206+
207+
let requestBody = try XCTUnwrap(request.httpBody ?? request.httpBodyStream?.readToEnd())
208+
209+
let expectedBody = "--\(boundary)\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nvalue\r\n--\(boundary)--\r\n"
210+
XCTAssertEqual(String(data: requestBody, encoding: .utf8), expectedBody)
211+
}
180212
}
181213

182214
private enum TestError: LocalizedError, Equatable {

0 commit comments

Comments
 (0)