Skip to content
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

Swift Create Media Support #412

Draft
wants to merge 8 commits into
base: trunk
Choose a base branch
from
Draft
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
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion native/kotlin/api/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,13 @@ val generateUniFFIBindingsTask = tasks.register<Exec>("generateUniFFIBindings")
inputs.dir("$cargoProjectRoot/$rustModuleName/")
}


tasks.named("compileKotlin").configure {
dependsOn(generateUniFFIBindingsTask)
}
tasks.named("processIntegrationTestResources").configure {
dependsOn(rootProject.tasks.named("copyDesktopJniLibs"))
dependsOn(rootProject.tasks.named("copyTestCredentials"))
dependsOn(rootProject.tasks.named("copyTestMedia"))
}

project.afterEvaluate {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package rs.wordpress.api.kotlin

import okhttp3.OkHttpClient
import okhttp3.Request
import uniffi.wp_api.UserId
import uniffi.wp_api.WpErrorCode

Expand All @@ -26,3 +28,9 @@ fun <T> WpRequestResult<T>.wpErrorCode(): WpErrorCode {
assert(this is WpRequestResult.WpError)
return (this as WpRequestResult.WpError).errorCode
}

fun restoreTestServer() {
OkHttpClient().newCall(
Request.Builder().url("http://localhost:4000/restore?db=true&plugins=true").build()
).execute()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package rs.wordpress.api.kotlin
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import uniffi.wp_api.MediaCreateParams
import uniffi.wp_api.MediaListParams
import uniffi.wp_api.SparseMediaFieldWithEditContext
import uniffi.wp_api.wpAuthenticationFromUsernameAndPassword
import kotlin.test.assertEquals
import kotlin.test.assertNotNull

private const val MEDIA_ID_611: Long = 611
Expand Down Expand Up @@ -63,4 +65,18 @@ class MediaEndpointTest {
assertNotNull(sparseMedia)
assertNull(sparseMedia.slug)
}

@Test
fun testCreateMediaRequest() = runTest {
val title = "Testing media upload from Kotlin"
val response = client.request { requestBuilder ->
requestBuilder.media().create(
params = MediaCreateParams(title = title),
"test_media.jpg",
"image/jpg"
)
}.assertSuccessAndRetrieveData().data
assertEquals(title, response.title.rendered)
restoreTestServer()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ constructor(
statusCode = exception.statusCode,
reason = exception.reason
)
is WpApiException.MediaFileNotFound -> WpRequestResult.MediaFileNotFound()
is WpApiException.ResponseParsingException -> WpRequestResult.ResponseParsingError(
reason = exception.reason,
response = exception.response,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ package rs.wordpress.api.kotlin
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import uniffi.wp_api.MediaUploadRequest
import uniffi.wp_api.MediaUploadRequestExecutionException
import uniffi.wp_api.RequestExecutor
import uniffi.wp_api.WpNetworkHeaderMap
import uniffi.wp_api.WpNetworkRequest
import uniffi.wp_api.WpNetworkResponse
import java.io.File

class WpRequestExecutor(
private val okHttpClient: OkHttpClient = OkHttpClient(),
Expand All @@ -29,6 +36,45 @@ class WpRequestExecutor(
}
}

okHttpClient.newCall(requestBuilder.build()).execute().use { response ->
return@withContext WpNetworkResponse(
body = response.body?.bytes() ?: ByteArray(0),
statusCode = response.code.toUShort(),
headerMap = WpNetworkHeaderMap.fromMultiMap(response.headers.toMultimap())
)
}
}

override suspend fun uploadMedia(mediaUploadRequest: MediaUploadRequest): WpNetworkResponse =
withContext(dispatcher) {
val requestBuilder = Request.Builder().url(mediaUploadRequest.url())
val multipartBodyBuilder = MultipartBody.Builder()
.setType(MultipartBody.FORM)
mediaUploadRequest.mediaParams().forEach { (k, v) ->
multipartBodyBuilder.addFormDataPart(k, v)
}
// TODO: This probably doesn't work with Android - and it looks wrong
val file =
WpRequestExecutor::class.java.classLoader?.getResource(mediaUploadRequest.filePath())?.file?.let {
File(
it
)
} ?: throw MediaUploadRequestExecutionException.MediaFileNotFound()
multipartBodyBuilder.addFormDataPart(
name = "file",
filename = file.name,
body = file.asRequestBody(mediaUploadRequest.fileContentType().toMediaType())
)
requestBuilder.method(
method = mediaUploadRequest.method().toString(),
body = multipartBodyBuilder.build()
)
mediaUploadRequest.headerMap().toMap().forEach { (key, values) ->
values.forEach { value ->
requestBuilder.addHeader(key, value)
}
}

okHttpClient.newCall(requestBuilder.build()).execute().use { response ->
return@withContext WpNetworkResponse(
body = response.body?.bytes() ?: ByteArray(0),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ sealed class WpRequestResult<T> {
val reason: String,
) : WpRequestResult<T>()

class MediaFileNotFound<T> : WpRequestResult<T>()

class SiteUrlParsingError<T>(
val reason: String,
) : WpRequestResult<T>()
Expand Down
11 changes: 11 additions & 0 deletions native/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,21 @@ fun setupJniAndBindings() {
into(jniLibsPath)
}

tasks.register<Delete>("deleteTestResources") {
delete = setOf(generatedTestResourcesPath)
}

tasks.register<Copy>("copyTestCredentials") {
dependsOn(tasks.named("deleteTestResources"))
from("$cargoProjectRoot/test_credentials.json")
into(generatedTestResourcesPath)
}

tasks.register<Copy>("copyTestMedia") {
dependsOn(tasks.named("deleteTestResources"))
from("$cargoProjectRoot/test_media.jpg")
into(generatedTestResourcesPath)
}
}

fun getNativeLibraryExtension(): String {
Expand Down
4 changes: 4 additions & 0 deletions native/swift/Sources/wordpress-api/Exports.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ public typealias SiteSettingsWithEditContext = WordPressAPIInternal.SiteSettings
public typealias SiteSettingsWithViewContext = WordPressAPIInternal.SiteSettingsWithViewContext
public typealias SiteSettingsWithEmbedContext = WordPressAPIInternal.SiteSettingsWithEmbedContext

// MARK: - Media
public typealias MediaUploadRequest = WordPressAPIInternal.MediaUploadRequest
public typealias MediaCreateParams = WordPressAPIInternal.MediaCreateParams

// swiftlint:enable line_length

#endif
4 changes: 2 additions & 2 deletions native/swift/Sources/wordpress-api/LoginAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ public final class WordPressLoginClient {
}
}

private let requestExecutor: SafeRequestExecutor
private let requestExecutor: RequestExecutor

public convenience init(urlSession: URLSession) {
self.init(requestExecutor: urlSession)
self.init(requestExecutor: SafeRequestExecutor(urlSession: urlSession))
}

init(requestExecutor: SafeRequestExecutor) {
Expand Down
151 changes: 151 additions & 0 deletions native/swift/Sources/wordpress-api/MultipartRequestBody.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import Foundation

struct MultipartRequestBody {

private let parts: [HttpPart]
let boundaryString: String = "wordpress-rs-swift-boundary"

private let boundaryMarker: Data = Data([0x2D, 0x2D])
private let lineBreak: Data = Data([0x0D, 0x0A])

var boundaryData: Data {
Data(boundaryString.utf8)
}

init(parts: [HttpPart] = []) {
self.parts = parts
}

func addPart(_ part: HttpPart) -> Self {
var mutableParts = self.parts
mutableParts.append(part)

return MultipartRequestBody(parts: mutableParts)
}

func build() async throws -> URL {
let filePath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try "".write(to: filePath, atomically: true, encoding: .utf8) // Create an empty file with built-in `throw`
let fileHandle = try FileHandle(forWritingTo: filePath)

for part in parts {
var data = Data()
data.append(contentsOf: boundaryMarker)
data.append(boundaryData)
data.append(contentsOf: lineBreak)
data.append(part.httpHeadersData)
try write(data, to: fileHandle)

for try await bodyData in part.readData() {
try write(bodyData, to: fileHandle)
}

try write(lineBreak, to: fileHandle)
}

try write(boundaryMarker + boundaryData + boundaryMarker + lineBreak, to: fileHandle)

try fileHandle.close()

return filePath
}

func write(_ data: Data, to fileHandle: FileHandle) throws {
if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
try fileHandle.write(contentsOf: data)
} else {
fileHandle.write(data)
}
}
}

enum HttpPart {

case formData(name: String, data: [String: String])
case file(name: String, filePath: URL, mimeType: String)

var httpHeaders: [String: String] {
return switch self {
case .formData(let name, _): [
"Content-Disposition": "form-data; name=\"\(name)\""
]
case .file(let name, let fileName, let mimeType): [
"Content-Disposition": "form-data; name=\"\(name)\"; filename=\"\(fileName.lastPathComponent)\"",
"Content-Type": mimeType
]
}
}

var httpHeadersData: Data {
Data(httpHeadersString.utf8)
}

var httpHeadersString: String {
httpHeaders
.sorted { $0.key < $1.key }
.compactMap { "\($0): \($1)" }
.joined(separator: "\r\n")
.appending("\r\n\r\n")
}

func readData() -> AsyncThrowingStream<Data, Error> {
switch self {
case .formData(name: _, data: let data):
return AsyncThrowingStream {
$0.yield(convertToFormData(data))
$0.finish()
}
case .file(_, let filePath, _):
return AsyncThrowingStream {
do {
let fileHandle = try FileHandle(forReadingFrom: filePath)

let chunkSize = 4_096_000 // Copy the file in 4MB chunks

repeat {
let newData = fileHandle.readData(ofLength: chunkSize)
$0.yield(newData)

if newData.count < chunkSize {
break
}
} while(true)

$0.finish()
} catch {
$0.finish(throwing: error)
}
}
}
}

func convertToFormData(_ data: [String: String]) -> Data {
Data(convertToFormString(data).utf8)
}

func convertToFormString(_ data: [String: String]) -> String {
data
.compactMap { (key: String, value: String) -> (String, String)? in
guard
let newKey = escape(key),
let newValue = escape(value)
else {
return nil
}

return (newKey, newValue)
}
.sorted { $0.0 < $1.0 }
.compactMap { "\($0)=\($1)" }
.joined(separator: "&")
}

func escape(_ string: String) -> String? {
var allowedCharacters = CharacterSet.alphanumerics
allowedCharacters.insert(charactersIn: "*-._ ")

return string
.addingPercentEncoding(withAllowedCharacters: allowedCharacters)?
.replacingOccurrences(of: " ", with: "+")
}
}
Loading