Skip to content

Implement uploading media in the Swift wrapper #715

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 9, 2025
Merged
Changes from 1 commit
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
Next Next commit
Build multipart-form content
  • Loading branch information
crazytonyli committed May 9, 2025
commit bcc0167526a3fc9d71357e1a8c89388b63124bba
193 changes: 193 additions & 0 deletions native/swift/Sources/wordpress-api/MultipartForm.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import Foundation

enum MultipartFormError: Swift.Error, LocalizedError {
case inaccessbileFile(underlyingError: Error)
case impossible

var errorDescription: String? {
switch self {
case let .inaccessbileFile(underlyingError: underlyingError):
return underlyingError.localizedDescription
case .impossible:
return "An unknown error occurred."
}
}
}

enum MultipartFormContent {
case inMemory(Data)
case onDisk(URL)

func asInputStream() -> InputStream {
switch self {
case let .inMemory(data):
return InputStream(data: data)
case let .onDisk(url):
precondition(url.isFileURL && FileManager.default.fileExists(atPath: url.path))
return InputStream(fileAtPath: url.path)!
}
}
}

struct MultipartFormField {
let name: String
let filename: String?
let mimeType: String?
let bytes: UInt64

fileprivate let inputStream: InputStream

init(text: String, name: String, filename: String? = nil, mimeType: String? = nil) {
self.init(data: text.data(using: .utf8)!, name: name, filename: filename, mimeType: mimeType)
}

init(data: Data, name: String, filename: String? = nil, mimeType: String? = nil) {
self.inputStream = InputStream(data: data)
self.name = name
self.filename = filename
self.bytes = UInt64(data.count)
self.mimeType = mimeType
}

init(fileAtPath path: String, name: String, filename: String? = nil, mimeType: String? = nil) throws {
let attrs: [FileAttributeKey: Any]
do {
attrs = try FileManager.default.attributesOfItem(atPath: path)
} catch {
throw MultipartFormError.inaccessbileFile(underlyingError: error)
}

guard let inputStream = InputStream(fileAtPath: path),
let bytes = (attrs[FileAttributeKey.size] as? NSNumber)?.uint64Value
else {
// Given we can successfully read the file attributes, the above calls should never fail.
throw MultipartFormError.impossible
}

self.inputStream = inputStream
self.name = name
self.filename = filename ?? path.split(separator: "/").last.flatMap({ String($0) })
self.bytes = bytes
self.mimeType = mimeType
}
}

extension Array where Element == MultipartFormField {
private func multipartFormDestination(forceWriteToFile: Bool) throws -> (outputStream: OutputStream, tempFilePath: String?) {
let dest: OutputStream
let tempFilePath: String?

// Build the form data in memory if the content is estimated to be less than 10 MB. Otherwise, use a temporary file.
let thresholdBytesForUsingTmpFile = 10_000_000
let estimatedFormDataBytes = reduce(0) { $0 + $1.bytes }
if forceWriteToFile || estimatedFormDataBytes > thresholdBytesForUsingTmpFile {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).path
guard let stream = OutputStream(toFileAtPath: tempFile, append: false) else {
// This error should never occurr, because the `tempFile` is in a temporary directory and is guranteed to be writable.
throw MultipartFormError.impossible
}
dest = stream
tempFilePath = tempFile
} else {
dest = OutputStream.toMemory()
tempFilePath = nil
}

return (dest, tempFilePath)
}

func multipartFormDataStream(boundary: String, forceWriteToFile: Bool = false) throws -> MultipartFormContent {
guard !isEmpty else {
return .inMemory(Data())
}

let (dest, tempFilePath) = try multipartFormDestination(forceWriteToFile: forceWriteToFile)

// Build the form content
do {
dest.open()
defer { dest.close() }

writeMultipartFormData(destination: dest, boundary: boundary)
}

// Return the result as `InputStream`
if let tempFilePath {
return .onDisk(URL(fileURLWithPath: tempFilePath))
}

if let data = dest.property(forKey: .dataWrittenToMemoryStreamKey) as? Data {
return .inMemory(data)
}

throw MultipartFormError.impossible
}

private func writeMultipartFormData(destination dest: OutputStream, boundary: String) {
for field in self {
dest.writeMultipartForm(boundary: boundary, isEnd: false)

// Write headers
var disposition = ["form-data", "name=\"\(field.name)\""]
if let filename = field.filename {
disposition += ["filename=\"\(filename)\""]
}
dest.writeMultipartFormHeader(name: "Content-Disposition", value: disposition.joined(separator: "; "))

if let mimeType = field.mimeType {
dest.writeMultipartFormHeader(name: "Content-Type", value: mimeType)
}

// Write a linebreak between header and content
dest.writeMultipartFormLineBreak()

// Write content
field.inputStream.open()
defer {
field.inputStream.close()
}
let maxLength = 1024
var buffer = [UInt8](repeating: 0, count: maxLength)
while field.inputStream.hasBytesAvailable {
let bytes = field.inputStream.read(&buffer, maxLength: maxLength)
dest.write(data: Data(bytesNoCopy: &buffer, count: bytes, deallocator: .none))
}

dest.writeMultipartFormLineBreak()
}

dest.writeMultipartForm(boundary: boundary, isEnd: true)
}
}

private let multipartFormDataLineBreak = "\r\n"
private extension OutputStream {
func write(data: Data) {
let count = data.count
guard count > 0 else { return }

_ = data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in
write(ptr.bindMemory(to: Int8.self).baseAddress!, maxLength: count)
}
}

func writeMultipartForm(lineContent: String) {
write(data: "\(lineContent)\(multipartFormDataLineBreak)".data(using: .utf8)!)
}

func writeMultipartFormLineBreak() {
write(data: multipartFormDataLineBreak.data(using: .utf8)!)
}

func writeMultipartFormHeader(name: String, value: String) {
writeMultipartForm(lineContent: "\(name): \(value)")
}

func writeMultipartForm(boundary: String, isEnd: Bool) {
if isEnd {
writeMultipartForm(lineContent: "--\(boundary)--")
} else {
writeMultipartForm(lineContent: "--\(boundary)")
}
}
}