Skip to content

Commit 1755d5d

Browse files
authored
Implement uploading media in the Swift wrapper (#715)
* Build multipart-form content * Implement uploading media in the Swift wrapper * Fix a compiling issue on Linux * Fix swiftlint format issues * Fix a swiftlint issue * Change upload_media error type to the correct one * Fix swiftlint issues
1 parent f528bb0 commit 1755d5d

File tree

6 files changed

+283
-21
lines changed

6 files changed

+283
-21
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public extension MiddlewarePipeline {
1212
}
1313

1414
extension WpNetworkResponse {
15-
init(data: Data, request: WpNetworkRequest, response: URLResponse) throws {
15+
init(data: Data, request: NetworkRequestContent, response: URLResponse) throws {
1616
guard let response = response as? HTTPURLResponse else {
1717
preconditionFailure("We should never wind up here")
1818
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ public final class DebugMiddleware: WpApiMiddleware {
66
response: WordPressAPIInternal.WpNetworkResponse,
77
request: WordPressAPIInternal.WpNetworkRequest
88
) async throws -> WordPressAPIInternal.WpNetworkResponse {
9-
debugPrint("Performed request: \(request.asURLRequest())")
9+
debugPrint("Performed request: \(String(describing: try? request.buildURLRequest()))")
1010
debugPrint("Received response: \(response)")
1111
return response
1212
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import Foundation
2+
3+
enum MultipartFormError: Swift.Error, LocalizedError {
4+
case inaccessbileFile(underlyingError: Error)
5+
case impossible
6+
7+
var errorDescription: String? {
8+
switch self {
9+
case let .inaccessbileFile(underlyingError: underlyingError):
10+
return underlyingError.localizedDescription
11+
case .impossible:
12+
return "An unknown error occurred."
13+
}
14+
}
15+
}
16+
17+
enum MultipartFormContent {
18+
case inMemory(Data)
19+
case onDisk(URL)
20+
21+
func asInputStream() -> InputStream {
22+
switch self {
23+
case let .inMemory(data):
24+
return InputStream(data: data)
25+
case let .onDisk(url):
26+
precondition(url.isFileURL && FileManager.default.fileExists(atPath: url.path))
27+
return InputStream(fileAtPath: url.path)!
28+
}
29+
}
30+
}
31+
32+
struct MultipartFormField {
33+
let name: String
34+
let filename: String?
35+
let mimeType: String?
36+
let bytes: UInt64
37+
38+
fileprivate let inputStream: InputStream
39+
40+
init(text: String, name: String, filename: String? = nil, mimeType: String? = nil) {
41+
self.init(data: text.data(using: .utf8)!, name: name, filename: filename, mimeType: mimeType)
42+
}
43+
44+
init(data: Data, name: String, filename: String? = nil, mimeType: String? = nil) {
45+
self.inputStream = InputStream(data: data)
46+
self.name = name
47+
self.filename = filename
48+
self.bytes = UInt64(data.count)
49+
self.mimeType = mimeType
50+
}
51+
52+
init(fileAtPath path: String, name: String, filename: String? = nil, mimeType: String? = nil) throws {
53+
let attrs: [FileAttributeKey: Any]
54+
do {
55+
attrs = try FileManager.default.attributesOfItem(atPath: path)
56+
} catch {
57+
throw MultipartFormError.inaccessbileFile(underlyingError: error)
58+
}
59+
60+
guard let inputStream = InputStream(fileAtPath: path),
61+
let bytes = (attrs[FileAttributeKey.size] as? NSNumber)?.uint64Value
62+
else {
63+
// Given we can successfully read the file attributes, the above calls should never fail.
64+
throw MultipartFormError.impossible
65+
}
66+
67+
self.inputStream = inputStream
68+
self.name = name
69+
self.filename = filename ?? path.split(separator: "/").last.flatMap({ String($0) })
70+
self.bytes = bytes
71+
self.mimeType = mimeType
72+
}
73+
}
74+
75+
extension Array where Element == MultipartFormField {
76+
private func multipartFormDestination(
77+
forceWriteToFile: Bool
78+
) throws -> (outputStream: OutputStream, tempFilePath: String?) {
79+
let dest: OutputStream
80+
let tempFilePath: String?
81+
82+
// Build the form data in memory if the content is estimated to be less than 10 MB.
83+
// Otherwise, use a temporary file.
84+
let thresholdBytesForUsingTmpFile = 10_000_000
85+
let estimatedFormDataBytes = reduce(0) { $0 + $1.bytes }
86+
if forceWriteToFile || estimatedFormDataBytes > thresholdBytesForUsingTmpFile {
87+
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).path
88+
guard let stream = OutputStream(toFileAtPath: tempFile, append: false) else {
89+
// This error should never occurr, because the `tempFile` is in a temporary directory
90+
// and is guranteed to be writable.
91+
throw MultipartFormError.impossible
92+
}
93+
dest = stream
94+
tempFilePath = tempFile
95+
} else {
96+
dest = OutputStream.toMemory()
97+
tempFilePath = nil
98+
}
99+
100+
return (dest, tempFilePath)
101+
}
102+
103+
func multipartFormDataStream(boundary: String, forceWriteToFile: Bool = false) throws -> MultipartFormContent {
104+
guard !isEmpty else {
105+
return .inMemory(Data())
106+
}
107+
108+
let (dest, tempFilePath) = try multipartFormDestination(forceWriteToFile: forceWriteToFile)
109+
110+
// Build the form content
111+
do {
112+
dest.open()
113+
defer { dest.close() }
114+
115+
writeMultipartFormData(destination: dest, boundary: boundary)
116+
}
117+
118+
// Return the result as `InputStream`
119+
if let tempFilePath {
120+
return .onDisk(URL(fileURLWithPath: tempFilePath))
121+
}
122+
123+
if let data = dest.property(forKey: .dataWrittenToMemoryStreamKey) as? Data {
124+
return .inMemory(data)
125+
}
126+
127+
throw MultipartFormError.impossible
128+
}
129+
130+
private func writeMultipartFormData(destination dest: OutputStream, boundary: String) {
131+
for field in self {
132+
dest.writeMultipartForm(boundary: boundary, isEnd: false)
133+
134+
// Write headers
135+
var disposition = ["form-data", "name=\"\(field.name)\""]
136+
if let filename = field.filename {
137+
disposition += ["filename=\"\(filename)\""]
138+
}
139+
dest.writeMultipartFormHeader(name: "Content-Disposition", value: disposition.joined(separator: "; "))
140+
141+
if let mimeType = field.mimeType {
142+
dest.writeMultipartFormHeader(name: "Content-Type", value: mimeType)
143+
}
144+
145+
// Write a linebreak between header and content
146+
dest.writeMultipartFormLineBreak()
147+
148+
// Write content
149+
field.inputStream.open()
150+
defer {
151+
field.inputStream.close()
152+
}
153+
let maxLength = 1024
154+
var buffer = [UInt8](repeating: 0, count: maxLength)
155+
while field.inputStream.hasBytesAvailable {
156+
let bytes = field.inputStream.read(&buffer, maxLength: maxLength)
157+
dest.write(data: Data(bytesNoCopy: &buffer, count: bytes, deallocator: .none))
158+
}
159+
160+
dest.writeMultipartFormLineBreak()
161+
}
162+
163+
dest.writeMultipartForm(boundary: boundary, isEnd: true)
164+
}
165+
}
166+
167+
private let multipartFormDataLineBreak = "\r\n"
168+
private extension OutputStream {
169+
func write(data: Data) {
170+
let count = data.count
171+
guard count > 0 else { return }
172+
173+
_ = data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in
174+
write(ptr.bindMemory(to: UInt8.self).baseAddress!, maxLength: count)
175+
}
176+
}
177+
178+
func writeMultipartForm(lineContent: String) {
179+
write(data: "\(lineContent)\(multipartFormDataLineBreak)".data(using: .utf8)!)
180+
}
181+
182+
func writeMultipartFormLineBreak() {
183+
write(data: multipartFormDataLineBreak.data(using: .utf8)!)
184+
}
185+
186+
func writeMultipartFormHeader(name: String, value: String) {
187+
writeMultipartForm(lineContent: "\(name): \(value)")
188+
}
189+
190+
func writeMultipartForm(boundary: String, isEnd: Bool) {
191+
if isEnd {
192+
writeMultipartForm(lineContent: "--\(boundary)--")
193+
} else {
194+
writeMultipartForm(lineContent: "--\(boundary)")
195+
}
196+
}
197+
}

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

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,21 @@ import FoundationNetworking
77

88
public protocol SafeRequestExecutor: RequestExecutor, Sendable {
99
func execute(_ request: WpNetworkRequest) async -> Result<WpNetworkResponse, RequestExecutionError>
10+
func uploadMedia(
11+
mediaUploadRequest: MediaUploadRequest
12+
) async -> Result<WpNetworkResponse, MediaUploadRequestExecutionError>
1013
}
1114

1215
extension SafeRequestExecutor {
1316
public func execute(request: WpNetworkRequest) async throws -> WpNetworkResponse {
1417
let result = await execute(request)
1518
return try result.get()
1619
}
20+
21+
public func uploadMedia(mediaUploadRequest: MediaUploadRequest) async throws -> WpNetworkResponse {
22+
let result = await uploadMedia(mediaUploadRequest: mediaUploadRequest)
23+
return try result.get()
24+
}
1725
}
1826

1927
public final class WpRequestExecutor: SafeRequestExecutor {
@@ -36,8 +44,28 @@ public final class WpRequestExecutor: SafeRequestExecutor {
3644
}
3745

3846
public func execute(_ request: WpNetworkRequest) async -> Result<WpNetworkResponse, RequestExecutionError> {
47+
await perform(request)
48+
}
49+
50+
public func uploadMedia(
51+
mediaUploadRequest: MediaUploadRequest
52+
) async -> Result<WpNetworkResponse, MediaUploadRequestExecutionError> {
53+
(await perform(mediaUploadRequest))
54+
.mapError { error in
55+
switch error {
56+
case let .RequestExecutionFailed(statusCode, redirects, reason):
57+
MediaUploadRequestExecutionError.RequestExecutionFailed(
58+
statusCode: statusCode,
59+
redirects: redirects,
60+
reason: reason
61+
)
62+
}
63+
}
64+
}
65+
66+
func perform(_ request: NetworkRequestContent) async -> Result<WpNetworkResponse, RequestExecutionError> {
3967
do {
40-
var urlrequest = request.asURLRequest()
68+
var urlrequest = try request.buildURLRequest()
4169

4270
// Set the user agent before `additionalHttpHeadersForAllRequests` so that it can be overridden that way
4371
urlrequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
@@ -80,7 +108,7 @@ public final class WpRequestExecutor: SafeRequestExecutor {
80108

81109
private func handleHttpsError(
82110
_ error: Error,
83-
for request: WpNetworkRequest
111+
for request: NetworkRequestContent
84112
) -> Result<WpNetworkResponse, RequestExecutionError> {
85113

86114
guard
@@ -101,7 +129,7 @@ public final class WpRequestExecutor: SafeRequestExecutor {
101129
redirects: executorDelegate.redirects(for: request.requestId()),
102130
reason: RequestExecutionErrorReason.invalidSslError(
103131
reason: .certificateNotValidForName(
104-
hostname: request.asURLRequest().url?.host ?? "unknown host",
132+
hostname: URL(string: request.url())?.host ?? "unknown host",
105133
presentedHostnames: [siteCertificate.commonName()]
106134
)
107135
)
@@ -110,7 +138,7 @@ public final class WpRequestExecutor: SafeRequestExecutor {
110138

111139
func handleNonExistentSiteError(
112140
_ error: Error,
113-
for request: WpNetworkRequest
141+
for request: NetworkRequestContent
114142
) -> Result<WpNetworkResponse, RequestExecutionError> {
115143
.failure(
116144
.RequestExecutionFailed(
@@ -124,10 +152,6 @@ public final class WpRequestExecutor: SafeRequestExecutor {
124152
)
125153
}
126154

127-
public func uploadMedia(mediaUploadRequest: MediaUploadRequest) async throws -> WpNetworkResponse {
128-
preconditionFailure("Not implemented yet")
129-
}
130-
131155
public func sleep(millis: UInt64) async {
132156
// swiftlint:disable:next force_try
133157
try! await Task.sleep(nanoseconds: millis * 1000)
@@ -243,3 +267,51 @@ extension URLRequest {
243267
allHTTPHeaderFields?["X-REQUEST-ID"]
244268
}
245269
}
270+
271+
protocol NetworkRequestContent {
272+
func requestId() -> String
273+
func method() -> RequestMethod
274+
func url() -> WpEndpointUrl
275+
func headerMap() -> WpNetworkHeaderMap
276+
func encodeBody(into request: inout URLRequest) throws
277+
}
278+
279+
extension NetworkRequestContent {
280+
func buildURLRequest() throws -> URLRequest {
281+
let url = URL(string: self.url())!
282+
var request = URLRequest(url: url)
283+
request.httpMethod = self.method().rawValue
284+
request.allHTTPHeaderFields = self.headerMap().toFlatMap()
285+
request.allHTTPHeaderFields?["X-REQUEST-ID"] = self.requestId()
286+
try self.encodeBody(into: &request)
287+
return request
288+
}
289+
}
290+
291+
extension WpNetworkRequest: NetworkRequestContent {
292+
293+
func encodeBody(into request: inout URLRequest) throws {
294+
if let body = self.body()?.contents() {
295+
request.httpBody = body
296+
}
297+
}
298+
299+
}
300+
301+
extension MediaUploadRequest: NetworkRequestContent {
302+
303+
func encodeBody(into request: inout URLRequest) throws {
304+
var form = [MultipartFormField]()
305+
for (name, value) in mediaParams() {
306+
form.append(.init(text: value, name: name))
307+
}
308+
try form.append(.init(fileAtPath: filePath(), name: "file"))
309+
310+
let boundery = String(format: "wordpressrs.%08x", Int.random(in: Int.min..<Int.max))
311+
request.setValue("multipart/form-data; boundary=\(boundery)", forHTTPHeaderField: "Content-Type")
312+
request.httpBodyStream = try form
313+
.multipartFormDataStream(boundary: boundery, forceWriteToFile: false)
314+
.asInputStream()
315+
}
316+
317+
}

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,6 @@ public extension WpNetworkHeaderMap {
111111

112112
public extension WpNetworkRequest {
113113

114-
func asURLRequest() -> URLRequest {
115-
let url = URL(string: self.url())!
116-
var request = URLRequest(url: url)
117-
request.httpMethod = self.method().rawValue
118-
request.allHTTPHeaderFields = self.headerMap().toFlatMap()
119-
request.allHTTPHeaderFields?["X-REQUEST-ID"] = self.requestId()
120-
request.httpBody = self.body()?.contents()
121-
return request
122-
}
123-
124114
#if DEBUG
125115
func debugPrint() {
126116
print("\(method().rawValue) \(self.url())")

native/swift/Tests/wordpress-api/Support/HTTPStubs.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import WordPressAPI
3+
import WordPressAPIInternal
34

45
#if canImport(FoundationNetworking)
56
import FoundationNetworking
@@ -41,7 +42,9 @@ final class HTTPStubs: SafeRequestExecutor {
4142
}
4243
}
4344

44-
func uploadMedia(mediaUploadRequest: MediaUploadRequest) async throws -> WpNetworkResponse {
45+
func uploadMedia(
46+
mediaUploadRequest: MediaUploadRequest
47+
) async -> Result<WpNetworkResponse, MediaUploadRequestExecutionError> {
4548
preconditionFailure("This method is not yet implemented")
4649
}
4750

0 commit comments

Comments
 (0)