diff --git a/Cargo.lock b/Cargo.lock index c6967ddaf..dd782d657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1697,6 +1697,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -1709,10 +1710,12 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "windows-registry", ] @@ -2939,6 +2942,19 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +[[package]] +name = "wasm-streams" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.69" diff --git a/native/kotlin/api/kotlin/build.gradle.kts b/native/kotlin/api/kotlin/build.gradle.kts index 67db7699f..d3d2cf6c3 100644 --- a/native/kotlin/api/kotlin/build.gradle.kts +++ b/native/kotlin/api/kotlin/build.gradle.kts @@ -115,13 +115,13 @@ val generateUniFFIBindingsTask = tasks.register("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 { diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt index 66463a71d..b0c875a50 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt @@ -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 @@ -26,3 +28,9 @@ fun WpRequestResult.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() +} \ No newline at end of file diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt index 00409dfc5..de343cf91 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt @@ -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 @@ -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() + } } diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpApiClient.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpApiClient.kt index 9987d2c43..c9c9fb6d6 100644 --- a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpApiClient.kt +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpApiClient.kt @@ -42,6 +42,9 @@ constructor( statusCode = exception.statusCode, reason = exception.reason ) + is WpApiException.MediaFileNotFound -> WpRequestResult.MediaFileNotFound( + filePath = exception.filePath + ) is WpApiException.ResponseParsingException -> WpRequestResult.ResponseParsingError( reason = exception.reason, response = exception.response, diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestExecutor.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestExecutor.kt index 6086b0743..b6905717f 100644 --- a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestExecutor.kt +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestExecutor.kt @@ -3,13 +3,19 @@ package rs.wordpress.api.kotlin import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +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(), @@ -29,6 +35,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) + } + val filePath = mediaUploadRequest.filePath() + val file = + WpRequestExecutor::class.java.classLoader?.getResource(filePath)?.file?.let { + File( + it + ) + } ?: throw MediaUploadRequestExecutionException.MediaFileNotFound(filePath) + 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), diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestResult.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestResult.kt index f487294f7..6c1962436 100644 --- a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestResult.kt +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestResult.kt @@ -20,6 +20,10 @@ sealed class WpRequestResult { val reason: String, ) : WpRequestResult() + class MediaFileNotFound( + val filePath: String + ) : WpRequestResult() + class SiteUrlParsingError( val reason: String, ) : WpRequestResult() diff --git a/native/kotlin/build.gradle.kts b/native/kotlin/build.gradle.kts index ea12a77fd..5657723eb 100644 --- a/native/kotlin/build.gradle.kts +++ b/native/kotlin/build.gradle.kts @@ -77,10 +77,21 @@ fun setupJniAndBindings() { into(jniLibsPath) } + tasks.register("deleteTestResources") { + delete = setOf(generatedTestResourcesPath) + } + tasks.register("copyTestCredentials") { + dependsOn(tasks.named("deleteTestResources")) from("$cargoProjectRoot/test_credentials.json") into(generatedTestResourcesPath) } + + tasks.register("copyTestMedia") { + dependsOn(tasks.named("deleteTestResources")) + from("$cargoProjectRoot/test_media.jpg") + into(generatedTestResourcesPath) + } } fun getNativeLibraryExtension(): String { diff --git a/native/swift/Sources/wordpress-api/Exports.swift b/native/swift/Sources/wordpress-api/Exports.swift index ef2ede586..39806042b 100644 --- a/native/swift/Sources/wordpress-api/Exports.swift +++ b/native/swift/Sources/wordpress-api/Exports.swift @@ -90,6 +90,7 @@ public typealias PostsRequestListWithEmbedContextResponse = WordPressAPIInternal // MARK: - Media public typealias SparseMedia = WordPressAPIInternal.SparseMedia +public typealias MediaUploadRequest = WordPressAPIInternal.MediaUploadRequest public typealias MediaWithEditContext = WordPressAPIInternal.MediaWithEditContext public typealias MediaWithViewContext = WordPressAPIInternal.MediaWithViewContext public typealias MediaWithEmbedContext = WordPressAPIInternal.MediaWithEmbedContext diff --git a/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift b/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift index ab0e38895..1b4011017 100644 --- a/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift +++ b/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift @@ -20,7 +20,11 @@ extension SafeRequestExecutor { } -extension URLSession: RequestExecutor {} +extension URLSession: RequestExecutor { + public func uploadMedia(mediaUploadRequest: MediaUploadRequest) async throws -> WpNetworkResponse { + try WpNetworkResponse(body: Data(), statusCode: 500, headerMap: .fromMap(hashMap: [:])) + } +} extension URLSession: SafeRequestExecutor { diff --git a/native/swift/Tests/wordpress-api/Support/HTTPStubs.swift b/native/swift/Tests/wordpress-api/Support/HTTPStubs.swift index eaa10fab7..b84d2bbba 100644 --- a/native/swift/Tests/wordpress-api/Support/HTTPStubs.swift +++ b/native/swift/Tests/wordpress-api/Support/HTTPStubs.swift @@ -30,6 +30,10 @@ final class HTTPStubs: SafeRequestExecutor { } } + func uploadMedia(mediaUploadRequest: MediaUploadRequest) async throws -> WpNetworkResponse { + try WpNetworkResponse(body: Data(), statusCode: 500, headerMap: .fromMap(hashMap: [:])) + } + private func stub(for request: WpNetworkRequest) -> WpNetworkResponse? { stubs.first { stub in stub.condition(request) }? .response diff --git a/test_media.jpg b/test_media.jpg new file mode 100644 index 000000000..85b2794c8 Binary files /dev/null and b/test_media.jpg differ diff --git a/wp_api/src/api_error.rs b/wp_api/src/api_error.rs index fc3443501..16d552e2b 100644 --- a/wp_api/src/api_error.rs +++ b/wp_api/src/api_error.rs @@ -23,6 +23,8 @@ pub enum WpApiError { status_code: Option, reason: String, }, + #[error("Media file not found at file path: {}", file_path)] + MediaFileNotFound { file_path: String }, #[error("Error while parsing. \nReason: {}\nResponse: {}", reason, response)] ResponseParsingError { reason: String, response: String }, #[error("Error while parsing site url: {}", reason)] @@ -320,6 +322,21 @@ pub enum RequestExecutionError { }, } +#[derive(Debug, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum MediaUploadRequestExecutionError { + #[error( + "Request execution failed!\nStatus Code: '{:?}'.\nResponse: '{}'", + status_code, + reason + )] + RequestExecutionFailed { + status_code: Option, + reason: String, + }, + #[error("Media file not found at file path: {}", file_path)] + MediaFileNotFound { file_path: String }, +} + impl From for WpApiError { fn from(value: RequestExecutionError) -> Self { match value { @@ -333,3 +350,20 @@ impl From for WpApiError { } } } + +impl From for WpApiError { + fn from(value: MediaUploadRequestExecutionError) -> Self { + match value { + MediaUploadRequestExecutionError::RequestExecutionFailed { + status_code, + reason, + } => Self::RequestExecutionFailed { + status_code, + reason, + }, + MediaUploadRequestExecutionError::MediaFileNotFound { file_path } => { + Self::MediaFileNotFound { file_path } + } + } + } +} diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index 0cfe1f974..c9ade3de7 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -1,7 +1,10 @@ #![allow(dead_code, unused_variables)] pub use api_client::{WpApiClient, WpApiRequestBuilder}; -pub use api_error::{ParsedRequestError, RequestExecutionError, WpApiError, WpError, WpErrorCode}; +pub use api_error::{ + MediaUploadRequestExecutionError, ParsedRequestError, RequestExecutionError, WpApiError, + WpError, WpErrorCode, +}; pub use parsed_url::{ParseUrlError, ParsedUrl}; use plugins::*; use serde::{Deserialize, Serialize}; diff --git a/wp_api/src/media.rs b/wp_api/src/media.rs index d4e0d92b1..964acfa49 100644 --- a/wp_api/src/media.rs +++ b/wp_api/src/media.rs @@ -11,7 +11,7 @@ use crate::{ EnumFromStrParsingError, JsonValue, UserId, WpApiParamOrder, }; use serde::{Deserialize, Serialize}; -use std::{num::ParseIntError, str::FromStr}; +use std::{collections::HashMap, num::ParseIntError, str::FromStr}; use strum_macros::IntoStaticStr; use wp_contextual::WpContextual; @@ -427,6 +427,98 @@ pub struct MediaDeleteResponse { pub previous: MediaWithEditContext, } +#[derive(Debug, Default, Serialize, uniffi::Record)] +pub struct MediaCreateParams { + /// The date the post was published, in the site's timezone. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option, + /// The date the post was published, as GMT. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub date_gmt: Option, + /// An alphanumeric identifier for the post unique to its type. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub slug: Option, + /// A named status for the post. + /// One of: publish, future, draft, pending, private + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// The title for the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// The ID for the author of the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + /// Whether or not comments are open on the post. + /// One of: open, closed + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub comment_status: Option, + /// Whether or not the post can be pinged. + /// One of: open, closed + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub ping_status: Option, + /// The theme file to use to display the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, + /// Alternative text to display when attachment is not displayed. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_text: Option, + /// The attachment caption. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub caption: Option, + /// The attachment description. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The ID for the associated post of the attachment. + #[serde(rename = "post")] + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub post_id: Option, + // meta field is omitted for now: https://github.com/Automattic/wordpress-rs/issues/381 +} + +impl From for HashMap { + fn from(params: MediaCreateParams) -> Self { + let mut map = HashMap::new(); + let mut add = |k: &str, v: Option| { + if let Some(v) = v { + map.insert(k.to_string(), v); + } + }; + add("date", params.date); + add("date_gmt", params.date_gmt); + add("slug", params.slug); + add("status", params.status.map(|x| x.as_str().to_string())); + add("title", params.title); + add("author", params.author.map(|x| x.to_string())); + add( + "comment_status", + params.comment_status.map(|x| x.as_str().to_string()), + ); + add( + "ping_status", + params.ping_status.map(|x| x.as_str().to_string()), + ); + add("template", params.template); + add("alt_text", params.alt_text); + add("caption", params.caption); + add("description", params.description); + add("post", params.post_id.map(|x| x.to_string())); + map + } +} + #[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] pub struct SparseMedia { #[WpContext(edit, embed, view)] diff --git a/wp_api/src/request.rs b/wp_api/src/request.rs index 7eee4bf42..49e696673 100644 --- a/wp_api/src/request.rs +++ b/wp_api/src/request.rs @@ -1,12 +1,14 @@ use std::{collections::HashMap, fmt::Debug, sync::Arc}; -use endpoint::ApiEndpointUrl; +use endpoint::{media_endpoint::MediaUploadRequest, ApiEndpointUrl}; use http::{HeaderMap, HeaderName, HeaderValue}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use url::Url; use crate::{ - api_error::{ParsedRequestError, RequestExecutionError, WpError}, + api_error::{ + MediaUploadRequestExecutionError, ParsedRequestError, RequestExecutionError, WpError, + }, url_query::{FromUrlQueryPairs, UrlQueryPairsMap}, WpApiError, WpAuthentication, }; @@ -16,6 +18,7 @@ use self::endpoint::WpEndpointUrl; pub mod endpoint; const CONTENT_TYPE_JSON: &str = "application/json"; +const CONTENT_TYPE_MULTIPART: &str = "multipart/form-data"; const LINK_HEADER_KEY: &str = "Link"; const HEADER_KEY_WP_TOTAL: &str = "X-WP-Total"; const HEADER_KEY_WP_TOTAL_PAGES: &str = "X-WP-TotalPages"; @@ -123,6 +126,11 @@ pub trait RequestExecutor: Send + Sync + Debug { &self, request: Arc, ) -> Result; + + async fn upload_media( + &self, + media_upload_request: Arc, + ) -> Result; } #[derive(uniffi::Object)] diff --git a/wp_api/src/request/endpoint/media_endpoint.rs b/wp_api/src/request/endpoint/media_endpoint.rs index 12ad01335..c61fdb194 100644 --- a/wp_api/src/request/endpoint/media_endpoint.rs +++ b/wp_api/src/request/endpoint/media_endpoint.rs @@ -1,12 +1,19 @@ -use super::{AsNamespace, DerivedRequest, WpNamespace}; +use std::{collections::HashMap, sync::Arc}; + +use super::{AsNamespace, DerivedRequest, WpEndpointUrl, WpNamespace}; use crate::{ media::{ - MediaId, MediaListParams, MediaUpdateParams, MediaWithEditContext, + MediaCreateParams, MediaId, MediaListParams, MediaUpdateParams, MediaWithEditContext, SparseMediaFieldWithEditContext, SparseMediaFieldWithEmbedContext, SparseMediaFieldWithViewContext, }, + request::{ + ParsedResponse, RequestMethod, WpNetworkHeaderMap, WpNetworkResponse, + CONTENT_TYPE_MULTIPART, + }, SparseField, }; +use http::HeaderValue; use wp_derive_request_builder::WpDerivedRequest; #[derive(WpDerivedRequest)] @@ -65,6 +72,156 @@ impl SparseField for SparseMediaFieldWithViewContext { } } +impl MediaRequestEndpoint { + pub fn create(&self) -> crate::request::endpoint::ApiEndpointUrl { + self.api_base_url + .by_extending_and_splitting_by_forward_slash([ + MediaRequest::namespace().as_str(), + "media", + ]) + .into() + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct MediaRequestCreateResponse { + pub data: crate::media::MediaWithEditContext, + #[serde(skip)] + pub header_map: std::sync::Arc, +} + +impl From for ParsedResponse { + fn from(value: MediaRequestCreateResponse) -> Self { + Self { + data: value.data, + header_map: value.header_map, + next_page_params: None, + prev_page_params: None, + } + } +} +impl From> for MediaRequestCreateResponse { + fn from(value: ParsedResponse) -> Self { + Self { + data: value.data, + header_map: value.header_map, + } + } +} + +#[uniffi::export] +fn parse_as_media_request_create_response( + response: WpNetworkResponse, +) -> Result { + response.parse() +} + +#[derive(uniffi::Object)] +pub struct MediaUploadRequest { + pub(crate) method: RequestMethod, + pub(crate) url: WpEndpointUrl, + pub(crate) header_map: Arc, + pub(crate) file_path: String, + pub(crate) file_content_type: String, + pub(crate) media_params: HashMap, +} + +#[uniffi::export] +impl MediaUploadRequest { + pub fn method(&self) -> RequestMethod { + self.method.clone() + } + + pub fn url(&self) -> WpEndpointUrl { + self.url.clone() + } + + pub fn header_map(&self) -> Arc { + self.header_map.clone() + } + + pub fn file_path(&self) -> String { + self.file_path.clone() + } + + pub fn file_content_type(&self) -> String { + self.file_content_type.clone() + } + + pub fn media_params(&self) -> HashMap { + self.media_params.clone() + } +} + +impl std::fmt::Debug for MediaUploadRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut s = format!( + indoc::indoc! {" + MediaUploadRequest {{ + method: '{:?}', + url: '{:?}', + header_map: '{:?}', + file_path: '{:?}' + file_content_type: '{:?}' + media_params: '{:?}' + }} + "}, + self.method, + self.url, + self.header_map, + self.file_path, + self.file_content_type, + self.media_params + ); + s.pop(); // Remove the new line at the end + write!(f, "{}", s) + } +} + +#[uniffi::export] +impl MediaRequestBuilder { + pub fn create( + &self, + params: MediaCreateParams, + file_path: String, + file_content_type: String, + ) -> MediaUploadRequest { + let url = self.endpoint.create(); + let mut header_map = self.inner.header_map(); + header_map.inner.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static(CONTENT_TYPE_MULTIPART), + ); + MediaUploadRequest { + method: RequestMethod::POST, + url: self.endpoint.create().into(), + header_map: header_map.into(), + file_path, + file_content_type, + media_params: params.into(), + } + } +} + +#[uniffi::export] +impl MediaRequestExecutor { + pub async fn create( + &self, + params: MediaCreateParams, + file_path: String, + file_content_type: String, + ) -> Result { + let request = self + .request_builder + .create(params, file_path, file_content_type); + self.request_executor + .upload_media(Arc::new(request)) + .await? + .parse() + } +} + #[cfg(test)] mod tests { use super::*; @@ -81,6 +238,11 @@ mod tests { use rstest::*; use std::sync::Arc; + #[rstest] + fn create_media(endpoint: MediaRequestEndpoint) { + validate_wp_v2_endpoint(endpoint.create(), "/media"); + } + #[rstest] fn delete_media(endpoint: MediaRequestEndpoint) { validate_wp_v2_endpoint(endpoint.delete(&MediaId(54)), "/media/54?force=true"); diff --git a/wp_api_integration_tests/Cargo.toml b/wp_api_integration_tests/Cargo.toml index 5b5fc0aa7..d5b02c599 100644 --- a/wp_api_integration_tests/Cargo.toml +++ b/wp_api_integration_tests/Cargo.toml @@ -13,7 +13,7 @@ async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } futures = { workspace = true } http = { workspace = true } -reqwest = { workspace = true, features = [ "json" ] } +reqwest = { workspace = true, features = [ "multipart", "json", "stream" ] } serde = { workspace = true, features = [ "derive" ] } serde_json = { workspace = true } tokio = { workspace = true, features = [ "full" ] } diff --git a/wp_api_integration_tests/src/lib.rs b/wp_api_integration_tests/src/lib.rs index 6d272d201..bd8e456fb 100644 --- a/wp_api_integration_tests/src/lib.rs +++ b/wp_api_integration_tests/src/lib.rs @@ -1,13 +1,17 @@ use async_trait::async_trait; +use http::{HeaderMap, HeaderValue}; +use reqwest::multipart::Part; use std::sync::Arc; use wp_api::{ media::MediaId, posts::{CategoryId, PostId, TagId}, request::{ - RequestExecutor, RequestMethod, WpNetworkHeaderMap, WpNetworkRequest, WpNetworkResponse, + endpoint::media_endpoint::MediaUploadRequest, RequestExecutor, RequestMethod, + WpNetworkHeaderMap, WpNetworkRequest, WpNetworkResponse, }, users::UserId, - ParsedUrl, RequestExecutionError, WpApiClient, WpApiError, WpAuthentication, WpErrorCode, + MediaUploadRequestExecutionError, ParsedUrl, RequestExecutionError, WpApiClient, WpApiError, + WpAuthentication, WpErrorCode, }; // A `TestCredentials::instance()` function will be generated by this @@ -159,6 +163,45 @@ impl AsyncWpNetworking { }) } + pub async fn upload_media_request( + &self, + media_upload_request: Arc, + ) -> Result { + let request = self + .client + .request( + Self::request_method(media_upload_request.method()), + media_upload_request.url().0.as_str(), + ) + .headers(media_upload_request.header_map().as_header_map()); + let file_path = media_upload_request.file_path(); + let mut file_header_map = HeaderMap::new(); + file_header_map.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_str(&media_upload_request.file_content_type()).unwrap(), + ); + let mut form = reqwest::multipart::Form::new().part( + "file", + Part::file(file_path) + .await + .unwrap() + .headers(file_header_map), + ); + for (k, v) in media_upload_request.media_params() { + form = form.text(k, v) + } + + let request = request.multipart(form); + let mut response = request.send().await?; + + let header_map = std::mem::take(response.headers_mut()); + Ok(WpNetworkResponse { + status_code: response.status().as_u16(), + body: response.bytes().await.unwrap().to_vec(), + header_map: Arc::new(WpNetworkHeaderMap::new(header_map)), + }) + } + fn request_method(method: RequestMethod) -> http::Method { match method { RequestMethod::GET => reqwest::Method::GET, @@ -182,6 +225,20 @@ impl RequestExecutor for AsyncWpNetworking { } }) } + + async fn upload_media( + &self, + media_upload_request: Arc, + ) -> Result { + self.upload_media_request(media_upload_request) + .await + .map_err( + |err| MediaUploadRequestExecutionError::RequestExecutionFailed { + status_code: err.status().map(|s| s.as_u16()), + reason: err.to_string(), + }, + ) + } } pub trait AssertResponse { diff --git a/wp_api_integration_tests/tests/test_media_mut.rs b/wp_api_integration_tests/tests/test_media_mut.rs index 99b2df80b..48e24ab24 100644 --- a/wp_api_integration_tests/tests/test_media_mut.rs +++ b/wp_api_integration_tests/tests/test_media_mut.rs @@ -1,13 +1,33 @@ use macro_helper::generate_update_test; use serial_test::serial; use wp_api::{ - media::MediaUpdateParams, + media::{MediaCreateParams, MediaUpdateParams}, posts::{PostCommentStatus, PostPingStatus, PostStatus}, }; use wp_api_integration_tests::{ api_client, backend::RestoreServer, AssertResponse, FIRST_POST_ID, MEDIA_ID_611, }; +#[tokio::test] +#[serial] +async fn upload_media() { + let title = "Foo media"; + let created_media = api_client() + .media() + .create( + MediaCreateParams { + title: Some(title.to_string()), + ..Default::default() + }, + "../test_media.jpg".to_string(), + "image/jpeg".to_string(), + ) + .await + .assert_response(); + assert_eq!(created_media.data.title.rendered.as_str(), title); + RestoreServer::db().await; +} + #[tokio::test] #[serial] async fn delete_media() { @@ -61,13 +81,6 @@ generate_update_test!( PostPingStatus::Closed ); -// TODO: `POST_TEMPLATE_SINGLE_WITH_SIDEBAR` doesn't work for `/media`. -//generate_update_test!( -// update_template, -// template, -// POST_TEMPLATE_SINGLE_WITH_SIDEBAR.to_string() -//); - generate_update_test!(update_alt_text, alt_text, "new_alt_text".to_string()); generate_update_test!(update_caption, caption, "new_caption".to_string());