Skip to content

Commit 26dcca9

Browse files
authored
Generalize the file uploading implementation (#977)
* Add `WpMultipartFormRequest` * Add `RequestExecutor.upload` * Add `multipart = true|false` attribute * Add missing upload implementation * Use a general multipart form upload to replace media upload * Update the Swift wrapper to use the new media upload API * Update the Kotlin wrapper to use the new media upload API * Fix rust linting issues * Fix Swift linting issues
1 parent b2e3e1e commit 26dcca9

File tree

26 files changed

+524
-482
lines changed

26 files changed

+524
-482
lines changed

native/kotlin/api/kotlin/src/integrationTest/kotlin/ApiUrlDiscoveryTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,5 +342,6 @@ private fun FetchAndParseApiRootFailure.getRequestExecutionErrorReason(): Reques
342342
private fun RequestExecutionException.reason(): RequestExecutionErrorReason? {
343343
return when (this) {
344344
is RequestExecutionException.RequestExecutionFailed -> this.reason
345+
is RequestExecutionException.MediaFileNotFound -> null
345346
}
346347
}

native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,7 @@ class MediaEndpointTest {
6767
val title = "Testing media upload from Kotlin"
6868
val response = client.request { requestBuilder ->
6969
requestBuilder.media().create(
70-
params = MediaCreateParams(title = title),
71-
"test_media.jpg",
72-
"image/jpeg",
73-
null
70+
params = MediaCreateParams(title = title, filePath = "test_media.jpg")
7471
)
7572
}.assertSuccessAndRetrieveData().data
7673
assertEquals(title, response.title.rendered)

native/kotlin/api/kotlin/src/integrationTest/kotlin/MockRequestExecutor.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ package rs.wordpress.api.kotlin
22

33
import kotlinx.coroutines.delay
44
import okio.FileNotFoundException
5-
import uniffi.wp_api.MediaUploadRequest
65
import uniffi.wp_api.RequestContext
76
import uniffi.wp_api.RequestExecutor
7+
import uniffi.wp_api.WpMultipartFormRequest
88
import uniffi.wp_api.WpNetworkHeaderMap
99
import uniffi.wp_api.WpNetworkRequest
1010
import uniffi.wp_api.WpNetworkResponse
@@ -41,7 +41,7 @@ class MockRequestExecutor(private var stubs: List<Stub> = listOf()) : RequestExe
4141
throw NoStubFoundException("No stub found for ${request.url()}")
4242
}
4343

44-
override suspend fun uploadMedia(mediaUploadRequest: MediaUploadRequest): WpNetworkResponse {
44+
override suspend fun upload(request: WpMultipartFormRequest): WpNetworkResponse {
4545
TODO("Not yet implemented")
4646
}
4747

native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestExecutor.kt

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,12 @@ import okhttp3.RequestBody
1414
import okhttp3.RequestBody.Companion.asRequestBody
1515
import okhttp3.RequestBody.Companion.toRequestBody
1616
import uniffi.wp_api.InvalidSslErrorReason
17-
import uniffi.wp_api.MediaUploadRequest
18-
import uniffi.wp_api.MediaUploadRequestExecutionException
1917
import uniffi.wp_api.RequestContext
2018
import uniffi.wp_api.RequestExecutionErrorReason
2119
import uniffi.wp_api.RequestExecutionException
2220
import uniffi.wp_api.RequestExecutor
2321
import uniffi.wp_api.RequestMethod
22+
import uniffi.wp_api.WpMultipartFormRequest
2423
import uniffi.wp_api.WpNetworkHeaderMap
2524
import uniffi.wp_api.WpNetworkRequest
2625
import uniffi.wp_api.WpNetworkResponse
@@ -85,54 +84,57 @@ class WpRequestExecutor(
8584
}
8685
}
8786

88-
override suspend fun uploadMedia(mediaUploadRequest: MediaUploadRequest): WpNetworkResponse =
87+
override suspend fun upload(request: WpMultipartFormRequest): WpNetworkResponse =
8988
withContext(dispatcher) {
90-
val requestBuilder = Request.Builder().url(mediaUploadRequest.url())
89+
val requestBuilder = Request.Builder().url(request.url())
9190
val multipartBodyBuilder = MultipartBody.Builder()
9291
.setType(MultipartBody.FORM)
93-
mediaUploadRequest.mediaParams().forEach { (k, v) ->
92+
request.fields().forEach { (k, v) ->
9493
multipartBodyBuilder.addFormDataPart(k, v)
9594
}
96-
val file = fileResolver.getFile(mediaUploadRequest.filePath())
97-
if (file == null || !file.canBeUploaded()) {
98-
throw MediaUploadRequestExecutionException.MediaFileNotFound(mediaUploadRequest.filePath())
95+
request.files().forEach { (name, fileInfo) ->
96+
val file = fileResolver.getFile(fileInfo.filePath)
97+
if (file == null || !file.canBeUploaded()) {
98+
throw RequestExecutionException.MediaFileNotFound(filePath = fileInfo.filePath)
99+
}
100+
val mimeType = fileInfo.mimeType ?: "application/octet-stream"
101+
val requestBody = getRequestBody(file, mimeType, uploadListener)
102+
val filename = fileInfo.fileName ?: file.name
103+
multipartBodyBuilder.addFormDataPart(
104+
name = name,
105+
filename = filename,
106+
body = requestBody
107+
)
99108
}
100-
val progressRequestBody = getRequestBody(file, mediaUploadRequest, uploadListener)
101-
multipartBodyBuilder.addFormDataPart(
102-
name = "file",
103-
filename = file.name,
104-
body = progressRequestBody
105-
)
106109
requestBuilder.method(
107-
method = mediaUploadRequest.method().toString(),
110+
method = request.method().toString(),
108111
body = multipartBodyBuilder.build()
109112
)
110-
mediaUploadRequest.headerMap().toMap().forEach { (key, values) ->
113+
request.headerMap().toMap().forEach { (key, values) ->
111114
values.forEach { value ->
112115
requestBuilder.addHeader(key, value)
113116
}
114117
}
115118

116119
val call = httpClient.getClient().newCall(requestBuilder.build())
117-
// Notify about the call creation so it can be cancelled if needed
118120
uploadListener?.onUploadStarted(CancellableCall(call))
119121
call.execute().use { response ->
120122
return@withContext WpNetworkResponse(
121123
body = response.body?.bytes() ?: ByteArray(0),
122124
statusCode = response.code.toUShort(),
123125
responseHeaderMap = WpNetworkHeaderMap.fromMultiMap(response.headers.toMultimap()),
124-
requestUrl = mediaUploadRequest.url(),
125-
requestHeaderMap = mediaUploadRequest.headerMap()
126+
requestUrl = request.url(),
127+
requestHeaderMap = request.headerMap()
126128
)
127129
}
128130
}
129131

130132
private fun getRequestBody(
131133
file: File,
132-
mediaUploadRequest: MediaUploadRequest,
134+
mimeType: String,
133135
uploadListener: UploadListener?
134136
): RequestBody {
135-
val fileRequestBody = file.asRequestBody(mediaUploadRequest.fileContentType().toMediaType())
137+
val fileRequestBody = file.asRequestBody(mimeType.toMediaType())
136138
return if (uploadListener != null) {
137139
ProgressRequestBody(
138140
delegate = fileRequestBody,

native/swift/Example/Example/UI/UploadView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,7 @@ private class UploadViewModel: ObservableObject {
190190

191191
NSLog("Uploading \(item)")
192192
_ = try await api.uploadMedia(
193-
params: .init(),
194-
fromLocalFileURL: file,
193+
params: .init(filePath: file.path),
195194
fulfilling: child
196195
)
197196

native/swift/Sources/wordpress-api/Exports.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ public typealias RevisionsRequestListWithEmbedContextResponse = WordPressAPIInte
145145

146146
// MARK: - Media
147147
public typealias SparseMedia = WordPressAPIInternal.SparseMedia
148-
public typealias MediaUploadRequest = WordPressAPIInternal.MediaUploadRequest
149148
public typealias MediaWithEditContext = WordPressAPIInternal.MediaWithEditContext
150149
public typealias MediaWithViewContext = WordPressAPIInternal.MediaWithViewContext
151150
public typealias MediaWithEmbedContext = WordPressAPIInternal.MediaWithEmbedContext

native/swift/Sources/wordpress-api/SafeRequestExecutor.swift

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import Foundation
22
import WordPressAPIInternal
33

4+
#if canImport(UniformTypeIdentifiers)
5+
import UniformTypeIdentifiers
6+
#endif
7+
48
#if canImport(FoundationNetworking)
59
import FoundationNetworking
610
#endif
@@ -11,9 +15,7 @@ import Combine
1115

1216
public protocol SafeRequestExecutor: RequestExecutor, Sendable {
1317
func execute(_ request: WpNetworkRequest) async -> Result<WpNetworkResponse, RequestExecutionError>
14-
func uploadMedia(
15-
mediaUploadRequest: MediaUploadRequest
16-
) async -> Result<WpNetworkResponse, MediaUploadRequestExecutionError>
18+
func upload(request: WpMultipartFormRequest) async -> Result<WpNetworkResponse, RequestExecutionError>
1719

1820
#if PROGRESS_REPORTING_ENABLED
1921
/// Returns a publisher that emits zero or one `Progress` instance representing the overall progress of the task
@@ -28,8 +30,8 @@ extension SafeRequestExecutor {
2830
return try result.get()
2931
}
3032

31-
public func uploadMedia(mediaUploadRequest: MediaUploadRequest) async throws -> WpNetworkResponse {
32-
let result = await uploadMedia(mediaUploadRequest: mediaUploadRequest)
33+
public func upload(request: WpMultipartFormRequest) async throws -> WpNetworkResponse {
34+
let result = await upload(request: request)
3335
return try result.get()
3436
}
3537
}
@@ -59,20 +61,8 @@ public final class WpRequestExecutor: SafeRequestExecutor {
5961
await perform(request)
6062
}
6163

62-
public func uploadMedia(
63-
mediaUploadRequest: MediaUploadRequest
64-
) async -> Result<WpNetworkResponse, MediaUploadRequestExecutionError> {
65-
(await perform(mediaUploadRequest))
66-
.mapError { error in
67-
switch error {
68-
case let .RequestExecutionFailed(statusCode, redirects, reason):
69-
MediaUploadRequestExecutionError.RequestExecutionFailed(
70-
statusCode: statusCode,
71-
redirects: redirects,
72-
reason: reason
73-
)
74-
}
75-
}
64+
public func upload(request: WpMultipartFormRequest) async -> Result<WpNetworkResponse, RequestExecutionError> {
65+
await perform(request)
7666
}
7767

7868
public func cancel(context: RequestContext) {
@@ -93,6 +83,10 @@ public final class WpRequestExecutor: SafeRequestExecutor {
9383

9484
return .success(try WpNetworkResponse(data: data, request: request, response: response))
9585
} catch {
86+
if let error = error as? RequestExecutionError {
87+
return .failure(error)
88+
}
89+
9690
if errorIsHttpsError(error) {
9791
return handleHttpsError(error, for: request)
9892
}
@@ -380,7 +374,7 @@ extension WpNetworkRequest: NetworkRequestContent {
380374
}
381375
}
382376

383-
extension MediaUploadRequest: NetworkRequestContent {
377+
extension WpMultipartFormRequest: NetworkRequestContent {
384378

385379
func encodeBody(into request: inout URLRequest) throws {
386380
// Do nothing.
@@ -394,10 +388,33 @@ extension MediaUploadRequest: NetworkRequestContent {
394388
var request = try buildURLRequest(additionalHeaders: headers)
395389

396390
var form = [MultipartFormField]()
397-
for (name, value) in mediaParams() {
391+
for (name, value) in fields() {
398392
form.append(.init(text: value, name: name))
399393
}
400-
try form.append(.init(fileAtPath: filePath(), name: "file"))
394+
for (name, file) in files() {
395+
var mimeType = file.mimeType
396+
397+
#if canImport(UniformTypeIdentifiers)
398+
if mimeType == nil {
399+
mimeType = UTType(
400+
filenameExtension: URL(fileURLWithPath: file.filePath).pathExtension
401+
)?.preferredMIMEType
402+
}
403+
#endif
404+
405+
do {
406+
try form.append(
407+
.init(
408+
fileAtPath: file.filePath,
409+
name: name,
410+
filename: file.fileName,
411+
mimeType: mimeType
412+
)
413+
)
414+
} catch {
415+
throw RequestExecutionError.MediaFileNotFound(filePath: file.filePath)
416+
}
417+
}
401418

402419
let boundery = String(format: "wordpressrs.%08x", Int.random(in: Int.min..<Int.max))
403420
request.setValue("multipart/form-data; boundary=\(boundery)", forHTTPHeaderField: "Content-Type")

native/swift/Sources/wordpress-api/WordPressAPI.swift

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ import FoundationNetworking
99
import Combine
1010
#endif
1111

12-
#if canImport(UniformTypeIdentifiers)
13-
import UniformTypeIdentifiers
14-
#endif
15-
1612
public actor WordPressAPI {
1713

1814
enum Errors: Error {
@@ -176,45 +172,47 @@ public actor WordPressAPI {
176172
#if PROGRESS_REPORTING_ENABLED
177173
public func uploadMedia(
178174
params: MediaCreateParams,
179-
fromLocalFileURL localFileURL: URL,
180-
fulfilling progress: Progress,
181-
mimeType: String? = nil,
175+
fulfilling progress: Progress
182176
) async throws -> MediaRequestCreateResponse {
183-
precondition(localFileURL.isFileURL)
184177
precondition(progress.completedUnitCount == 0 && progress.totalUnitCount > 0)
185178
precondition(progress.cancellationHandler == nil)
186179

187-
let requestId = WpUuid()
180+
let context = RequestContext()
188181

189-
let fileContentType: String
190-
if let mimeType {
191-
fileContentType = mimeType
192-
} else if let mimeType = UTType(filenameExtension: localFileURL.pathExtension)?.preferredMIMEType {
193-
fileContentType = mimeType
194-
} else {
195-
fileContentType = "application/octet-stream"
182+
let uploadTask = Task {
183+
try await media.createCancellation(params: params, context: context)
196184
}
197185

198-
let cancellable = requestExecutor
199-
.progress(forRequestWithId: requestId.uuidString())
200-
.sink {
201-
progress.addChild($0, withPendingUnitCount: progress.totalUnitCount - progress.completedUnitCount)
186+
let progressObserver = Task {
187+
// A request id will be put into the `RequestContext` during the execution of the `media.create` above.
188+
// This loop waits for the request id becomes available
189+
let requestId: String
190+
while true {
191+
try await Task.sleep(nanoseconds: 100_000)
192+
try Task.checkCancellation()
193+
194+
guard let id = context.requestIds().first else {
195+
continue
196+
}
197+
198+
requestId = id
199+
break
202200
}
203-
defer {
204-
cancellable.cancel()
205-
}
206201

207-
let uploadTask = Task {
208-
try await media.create(
209-
params: params,
210-
filePath: localFileURL.path,
211-
fileContentType: fileContentType,
212-
requestId: requestId
213-
)
202+
// Get the progress of the `URLSessionTask` of the given request id.
203+
guard let task = await requestExecutor
204+
.progress(forRequestWithId: requestId)
205+
.values
206+
.first(where: { _ in true }) else { return }
207+
208+
try Task.checkCancellation()
209+
210+
progress.addChild(task, withPendingUnitCount: progress.totalUnitCount - progress.completedUnitCount)
214211
}
215212

216213
progress.cancellationHandler = {
217214
uploadTask.cancel()
215+
progressObserver.cancel()
218216
}
219217

220218
return try await withTaskCancellationHandler {

0 commit comments

Comments
 (0)