Skip to content

Commit f20cde8

Browse files
authored
Parse the media_details JSON into concrete instances (#750)
* Parse the `media_details` JSON into concrete instances * Update the Swift example app to include some media details * Add integration tests for media details parsing * Fix swiftlint issues * Remove a trailing semicolon
1 parent 3d60a4d commit f20cde8

File tree

6 files changed

+269
-11
lines changed

6 files changed

+269
-11
lines changed

native/swift/Example/Example/ListViewData.swift

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,13 @@ extension PostWithEditContext: ListViewDataConvertable {
139139

140140
extension MediaWithEditContext: ListViewDataConvertable {
141141
var asListViewData: ListViewData {
142-
ListViewData(id: self.slug, title: self.title.raw, subtitle: String(describing: self.mediaDetails), fields: [:])
142+
let details = self.mediaDetails.parseAsMimeType(mimeType: self.mimeType)
143+
return ListViewData(
144+
id: self.slug,
145+
title: details.emoji + " " + (URL(string: self.sourceUrl)?.lastPathComponent ?? "<invalid-source-url>"),
146+
subtitle: self.title.raw,
147+
fields: details.fields
148+
)
143149
}
144150
}
145151

@@ -160,3 +166,43 @@ extension [ListViewDataConvertable] {
160166
self.map { $0.asListViewData }
161167
}
162168
}
169+
170+
private extension Optional<MediaDetailsPayload> {
171+
var emoji: String {
172+
switch self {
173+
case .audio:
174+
"🔊"
175+
case .image:
176+
"🌆"
177+
case .video:
178+
"🎥"
179+
case .document:
180+
"📁"
181+
case nil:
182+
""
183+
}
184+
}
185+
186+
var fields: [String: String] {
187+
var fields = [String: String]()
188+
189+
switch self {
190+
case let .audio(audio):
191+
fields["Duration"] = "\(audio.length) seconds"
192+
fields["File size"] = "\(audio.fileSize) bytes"
193+
case let .image(image):
194+
fields["Size"] = "\(image.width)x\(image.height) pixels"
195+
fields["File size"] = "\(image.fileSize) bytes"
196+
case let .video(video):
197+
fields["Size"] = "\(video.width)x\(video.height) pixels"
198+
fields["Duration"] = "\(video.length) seconds"
199+
fields["File size"] = "\(video.fileSize) bytes"
200+
case let .document(doc):
201+
fields["File size"] = "\(doc.fileSize) bytes"
202+
case nil:
203+
break
204+
}
205+
206+
return fields
207+
}
208+
}

wp_api/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ roxmltree = { workspace = true }
3232
rustls = { workspace = true, optional = true }
3333
scraper = { workspace = true }
3434
serde = { workspace = true, features = [ "derive", "rc" ] }
35-
serde_json = { workspace = true }
35+
serde_json = { workspace = true, features = [ "raw_value" ] }
3636
serde_repr = { workspace = true }
3737
strum = { workspace = true }
3838
strum_macros = { workspace = true }

wp_api/src/media.rs

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{
2-
JsonValue, UserId, WpApiParamOrder,
2+
UserId, WpApiParamOrder,
33
date::WpGmtDateTime,
44
impl_as_query_value_for_new_type, impl_as_query_value_from_to_string,
55
posts::{
@@ -11,6 +11,8 @@ use crate::{
1111
},
1212
};
1313
use serde::{Deserialize, Serialize};
14+
use serde_json::value::RawValue;
15+
use std::sync::Arc;
1416
use std::{collections::HashMap, num::ParseIntError, str::FromStr};
1517
use strum_macros::IntoStaticStr;
1618
use wp_contextual::WpContextual;
@@ -527,7 +529,7 @@ pub struct SparseMedia {
527529
#[WpContext(edit, embed, view)]
528530
pub mime_type: Option<String>,
529531
#[WpContext(edit, embed, view)]
530-
pub media_details: Option<JsonValue>,
532+
pub media_details: Option<Arc<MediaDetails>>,
531533
#[serde(rename = "post")]
532534
#[WpContext(edit, view)]
533535
#[WpContextualOption]
@@ -539,6 +541,107 @@ pub struct SparseMedia {
539541
// meta field is omitted for now: https://github.com/Automattic/wordpress-rs/issues/381
540542
}
541543

544+
#[derive(Debug, Serialize, Deserialize, uniffi::Object)]
545+
#[serde(transparent)]
546+
pub struct MediaDetails {
547+
payload: Box<RawValue>,
548+
}
549+
550+
#[uniffi::export]
551+
impl MediaDetails {
552+
pub fn parse_as_mime_type(&self, mime_type: String) -> Option<MediaDetailsPayload> {
553+
if mime_type.starts_with("image/") {
554+
return serde_json::from_str::<ImageMediaDetails>(self.payload.get())
555+
.ok()
556+
.map(MediaDetailsPayload::Image);
557+
} else if mime_type.starts_with("video/") || mime_type.starts_with("audio/") {
558+
// Some audio files may be returned with a video MIME type, so we attempt to parse both.
559+
560+
if let Ok(details) = serde_json::from_str::<VideoMediaDetails>(self.payload.get()) {
561+
return Some(MediaDetailsPayload::Video(details));
562+
}
563+
564+
if let Ok(details) = serde_json::from_str::<AudioMediaDetails>(self.payload.get()) {
565+
return Some(MediaDetailsPayload::Audio(details));
566+
}
567+
}
568+
569+
serde_json::from_str::<DocumentMediaDetails>(self.payload.get())
570+
.ok()
571+
.map(MediaDetailsPayload::Document)
572+
}
573+
}
574+
575+
#[derive(Debug, uniffi::Enum)]
576+
pub enum MediaDetailsPayload {
577+
Audio(AudioMediaDetails),
578+
Image(ImageMediaDetails),
579+
Video(VideoMediaDetails),
580+
Document(DocumentMediaDetails),
581+
}
582+
583+
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
584+
pub struct AudioMediaDetails {
585+
#[serde(rename = "filesize")]
586+
pub file_size: u64,
587+
pub length: u64,
588+
pub length_formatted: String,
589+
590+
#[serde(rename = "dataformat")]
591+
pub data_format: Option<String>,
592+
pub codec: Option<String>,
593+
pub sample_rate: Option<u32>,
594+
pub channels: Option<u8>,
595+
pub bits_per_sample: Option<u8>,
596+
pub lossless: Option<bool>,
597+
#[serde(rename = "channelmode")]
598+
pub channel_mode: Option<String>,
599+
pub bitrate: Option<f64>,
600+
pub compression_ratio: Option<f64>,
601+
#[serde(rename = "fileformat")]
602+
pub file_format: Option<String>,
603+
}
604+
605+
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
606+
pub struct ImageMediaDetails {
607+
#[serde(rename = "filesize")]
608+
pub file_size: u64,
609+
610+
pub width: u32,
611+
pub height: u32,
612+
pub file: String,
613+
pub sizes: Option<HashMap<String, ScaledImageDetails>>,
614+
}
615+
616+
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
617+
pub struct ScaledImageDetails {
618+
pub file: String,
619+
pub width: u32,
620+
pub height: u32,
621+
pub source_url: String,
622+
}
623+
624+
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
625+
pub struct VideoMediaDetails {
626+
#[serde(rename = "filesize")]
627+
pub file_size: u64,
628+
pub length: u32,
629+
pub width: u32,
630+
pub height: u32,
631+
632+
#[serde(rename = "fileformat")]
633+
pub file_format: Option<String>,
634+
#[serde(rename = "dataformat")]
635+
pub data_format: Option<String>,
636+
pub created_timestamp: Option<u64>,
637+
}
638+
639+
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
640+
pub struct DocumentMediaDetails {
641+
#[serde(rename = "filesize")]
642+
pub file_size: u64,
643+
}
644+
542645
#[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)]
543646
pub struct SparseMediaDescription {
544647
#[WpContext(edit)]
@@ -650,4 +753,60 @@ mod tests {
650753
"page=11&per_page=22&search=s_q&{after}&{modified_after}&author=111%2C112&author_exclude=211%2C212&{before}&{modified_before}&exclude=1111%2C1112&include=2111%2C2112&offset=11111&order=desc&orderby=slug&parent=44444%2C44445&search_columns=post_content%2Cpost_excerpt&slug=sl_1%2Csl_2&status=inherit%2Cprivate%2Ctrash&parent_exclude=55555%2C55556&media_type=image&mime_type=image%2Fjpeg"
651754
)
652755
}
756+
757+
#[test]
758+
fn media_details_round_trip() {
759+
let original = r#"
760+
{
761+
"id": 11,
762+
"date": "2025-05-29T03:15:55",
763+
"date_gmt": "2025-05-29T03:15:55",
764+
"guid": { "rendered": "https://example.com/dummy.docx" },
765+
"modified": "2025-05-29T03:15:55",
766+
"modified_gmt": "2025-05-29T03:15:55",
767+
"slug": "dummy-slug",
768+
"status": "dummy-status",
769+
"type": "dummy-type",
770+
"link": "https://example.com/dummy-link/",
771+
"title": { "rendered": "Dummy Title" },
772+
"author": 1,
773+
"featured_media": 0,
774+
"comment_status": "dummy-comment-status",
775+
"ping_status": "dummy-ping-status",
776+
"template": "dummy-template",
777+
"meta": [],
778+
"class_list": [
779+
"dummy-class-1",
780+
"dummy-class-2",
781+
"dummy-class-3",
782+
"dummy-class-4",
783+
"dummy-class-5"
784+
],
785+
"description": {
786+
"rendered": "<p class=\"dummy-class\"><a href='https://example.com/dummy.docx'>Dummy Link</a></p>\n"
787+
},
788+
"caption": {
789+
"rendered": "<p>Dummy Caption</p>\n"
790+
},
791+
"alt_text": "Dummy Alt Text",
792+
"media_type": "dummy-media-type",
793+
"mime_type": "dummy/mime-type",
794+
"media_details": {
795+
"filesize": 7378,
796+
"sizes": {}
797+
},
798+
"post": null,
799+
"source_url": "https://example.com/dummy.docx"
800+
}
801+
"#;
802+
803+
let media = serde_json::from_str::<MediaWithViewContext>(original).unwrap();
804+
let serialized = serde_json::to_string(&media).unwrap();
805+
let json = serde_json::from_str::<serde_json::Value>(serialized.as_str()).unwrap();
806+
assert_eq!(json["media_details"]["filesize"], 7378);
807+
assert_eq!(
808+
json["media_details"]["sizes"],
809+
serde_json::Value::Object(serde_json::Map::new())
810+
);
811+
}
653812
}

wp_api_integration_tests/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ pub const POST_ID_555: PostId = PostId(555);
5555
pub const POST_ID_DRAFT: PostId = PostId(1164);
5656
pub const POST_ID_INVALID: PostId = PostId(99999999);
5757
pub const MEDIA_ID_611: MediaId = MediaId(611);
58+
pub const MEDIA_ID_VIDEO: MediaId = MediaId(1690);
59+
pub const MEDIA_ID_AUDIO: MediaId = MediaId(821);
60+
pub const MEDIA_ID_IMAGE: MediaId = MediaId(1692);
5861
pub const MEDIA_TEST_FILE_PATH: &str = "../test-data/test_media.jpg";
5962
pub const MEDIA_TEST_FILE_CONTENT_TYPE: &str = "image/jpeg";
6063
pub const CATEGORY_ID_48: CategoryId = CategoryId(48);

wp_api_integration_tests/src/prelude.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ pub use crate::{
22
AssertResponse, AssertWpError, CATEGORY_ID_48, CATEGORY_ID_59, CATEGORY_ID_INVALID,
33
CLASSIC_EDITOR_PLUGIN_SLUG, COMMENT_ID_INVALID, EmptyAppNotifier, FIRST_COMMENT_ID,
44
FIRST_POST_ID, FIRST_USER_EMAIL, FIRST_USER_ID, HELLO_DOLLY_PLUGIN_SLUG, MEDIA_ID_611,
5-
MEDIA_TEST_FILE_CONTENT_TYPE, MEDIA_TEST_FILE_PATH, POST_ID_555, POST_ID_DRAFT,
6-
POST_ID_INVALID, POST_TEMPLATE_SINGLE_WITH_SIDEBAR, SECOND_COMMENT_ID, SECOND_USER_EMAIL,
7-
SECOND_USER_ID, SECOND_USER_SLUG, TAG_ID_100, TAG_ID_INVALID,
8-
TEMPLATE_TWENTY_TWENTY_FOUR_SINGLE, THEME_TWENTY_TWENTY_FIVE, THEME_TWENTY_TWENTY_FOUR,
9-
THEME_TWENTY_TWENTY_THREE, TestCredentials, USER_ID_INVALID,
10-
WP_ORG_PLUGIN_SLUG_CLASSIC_WIDGETS, api_client, api_client_as_author, api_client_as_subscriber,
11-
api_client_with_auth_provider,
5+
MEDIA_ID_AUDIO, MEDIA_ID_IMAGE, MEDIA_ID_VIDEO, MEDIA_TEST_FILE_CONTENT_TYPE,
6+
MEDIA_TEST_FILE_PATH, POST_ID_555, POST_ID_DRAFT, POST_ID_INVALID,
7+
POST_TEMPLATE_SINGLE_WITH_SIDEBAR, SECOND_COMMENT_ID, SECOND_USER_EMAIL, SECOND_USER_ID,
8+
SECOND_USER_SLUG, TAG_ID_100, TAG_ID_INVALID, TEMPLATE_TWENTY_TWENTY_FOUR_SINGLE,
9+
THEME_TWENTY_TWENTY_FIVE, THEME_TWENTY_TWENTY_FOUR, THEME_TWENTY_TWENTY_THREE, TestCredentials,
10+
USER_ID_INVALID, WP_ORG_PLUGIN_SLUG_CLASSIC_WIDGETS, api_client, api_client_as_author,
11+
api_client_as_subscriber, api_client_with_auth_provider,
1212
backend::{Backend, RestoreServer},
1313
mock::{MockExecutor, response_helpers},
1414
test_site_api_url_resolver, test_site_url, unwrapped_wp_gmt_date_time,

wp_api_integration_tests/tests/test_media_immut.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ async fn paginate_list_media_with_edit_context(#[case] params: MediaListParams)
126126
pub fn list_cases(#[case] params: MediaListParams) {}
127127

128128
mod filter {
129+
use wp_api::media::MediaDetailsPayload;
130+
129131
use super::*;
130132

131133
wp_api::generate_sparse_media_field_with_edit_context_test_cases!();
@@ -254,4 +256,52 @@ mod filter {
254256
.data;
255257
media.assert_that_instance_fields_nullability_match_provided_fields(fields);
256258
}
259+
260+
#[tokio::test]
261+
#[parallel]
262+
async fn parse_video_media_details() {
263+
let media = api_client()
264+
.media()
265+
.retrieve_with_edit_context(&MEDIA_ID_VIDEO)
266+
.await
267+
.assert_response()
268+
.data;
269+
270+
assert!(matches!(
271+
media.media_details.parse_as_mime_type(media.mime_type),
272+
Some(MediaDetailsPayload::Video { .. })
273+
));
274+
}
275+
276+
#[tokio::test]
277+
#[parallel]
278+
async fn parse_audio_media_details() {
279+
let media = api_client()
280+
.media()
281+
.retrieve_with_edit_context(&MEDIA_ID_AUDIO)
282+
.await
283+
.assert_response()
284+
.data;
285+
286+
assert!(matches!(
287+
media.media_details.parse_as_mime_type(media.mime_type),
288+
Some(MediaDetailsPayload::Audio { .. })
289+
));
290+
}
291+
292+
#[tokio::test]
293+
#[parallel]
294+
async fn parse_image_media_details() {
295+
let media = api_client()
296+
.media()
297+
.retrieve_with_edit_context(&MEDIA_ID_IMAGE)
298+
.await
299+
.assert_response()
300+
.data;
301+
302+
assert!(matches!(
303+
media.media_details.parse_as_mime_type(media.mime_type),
304+
Some(MediaDetailsPayload::Image { .. })
305+
));
306+
}
257307
}

0 commit comments

Comments
 (0)