From 8ef9173ac29ed80094bff665f33d6ab58a6d55f6 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Sat, 30 May 2026 16:12:10 -0400 Subject: [PATCH] Prototype: wrap `MediaType` in a UniFFI object to prevent fallback variant breakage When a new named variant is added to an enum with a fallback (e.g. `Custom(String)`), Kotlin/Swift code checking `is CustomException` silently stops matching. This wraps `MediaType` in a `MediaTypeWrapper` UniFFI object so clients must use `isMediaType()` for comparison, which normalizes through the string representation and is resilient to variant promotion. --- .../kotlin/MediaEndpointTest.kt | 22 +++++++++ wp_api/src/media.rs | 49 ++++++++++++++++++- wp_mobile/src/filters/media_list_filter.rs | 2 +- wp_mobile_cache/src/repository/media.rs | 5 +- wp_mobile_cache/src/test_fixtures/media.rs | 8 +-- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt index 78c46c780..224661dee 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/MediaEndpointTest.kt @@ -5,11 +5,13 @@ 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.MediaType import uniffi.wp_api.SparseMediaFieldWithEditContext import uniffi.wp_api.WpAuthenticationProvider import java.io.File import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue private const val MEDIA_ID_611: Long = 611 @@ -32,6 +34,26 @@ class MediaEndpointTest { assertNotNull(media) } + @Test + fun testMediaTypeWrapperComparison() = runTest { + val media = client.request { requestBuilder -> + requestBuilder.media().filterRetrieveWithEditContext( + mediaId = MEDIA_ID_611, + fields = listOf(SparseMediaFieldWithEditContext.MEDIA_TYPE) + ) + }.assertSuccessAndRetrieveData().data + val mediaType = assertNotNull(media.mediaType) + + // The wrapper's isMediaType should match the named variant + assertTrue(mediaType.isMediaType(MediaType.Image)) + + // Crucially: it should also match Custom("image") — this is the forward-compat + // guarantee. Code written before `Image` was a named variant used + // Custom("image") to check for images. After `Image` gets promoted to a named + // variant, that comparison must still work. + assertTrue(mediaType.isMediaType(MediaType.Custom("image"))) + } + @Test fun testFilterMediaListRequest() = runTest { val mediaList = client.request { requestBuilder -> diff --git a/wp_api/src/media.rs b/wp_api/src/media.rs index cb02eb089..c21c95c84 100644 --- a/wp_api/src/media.rs +++ b/wp_api/src/media.rs @@ -47,6 +47,53 @@ pub enum MediaType { impl_as_query_value_from_to_string!(MediaType); +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Object)] +#[serde(transparent)] +pub struct MediaTypeWrapper { + inner: MediaType, +} + +impl PartialEq for MediaTypeWrapper { + fn eq(&self, other: &Self) -> bool { + self.inner.to_string() == other.inner.to_string() + } +} + +impl Eq for MediaTypeWrapper {} + +impl std::fmt::Display for MediaTypeWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.inner.fmt(f) + } +} + +impl std::str::FromStr for MediaTypeWrapper { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + MediaType::from_str(s).map(|inner| Self { inner }) + } +} + +impl MediaTypeWrapper { + pub fn inner(&self) -> &MediaType { + &self.inner + } +} + +impl From for MediaTypeWrapper { + fn from(inner: MediaType) -> Self { + Self { inner } + } +} + +#[uniffi::export] +impl MediaTypeWrapper { + fn is_media_type(&self, media_type: MediaType) -> bool { + self.inner.to_string() == media_type.to_string() + } +} + // A separate param type is implemented for `MediaType` because the API will accept types such as // "audio" & "application" as a parameter, but it'll return "file" for the value of `media_type` // field for these media types. @@ -409,7 +456,7 @@ pub struct SparseMedia { #[WpContextualField] pub description: Option, #[WpContext(edit, embed, view)] - pub media_type: Option, + pub media_type: Option>, #[WpContext(edit, embed, view)] pub mime_type: Option, #[WpContext(edit, embed, view)] diff --git a/wp_mobile/src/filters/media_list_filter.rs b/wp_mobile/src/filters/media_list_filter.rs index 5824ad196..a067f03a7 100644 --- a/wp_mobile/src/filters/media_list_filter.rs +++ b/wp_mobile/src/filters/media_list_filter.rs @@ -153,7 +153,7 @@ impl MediaListFilter { // Media type check. if let Some(param) = &self.media_type { let media_mime_top = media.mime_type.split_once('/').map(|(top, _)| top); - let matches = match (param, &media.media_type) { + let matches = match (param, media.media_type.inner()) { (MediaTypeParam::Image, MediaType::Image) => true, (MediaTypeParam::Image, MediaType::File) => false, (MediaTypeParam::Video, MediaType::File) => media_mime_top == Some("video"), diff --git a/wp_mobile_cache/src/repository/media.rs b/wp_mobile_cache/src/repository/media.rs index 5f9767dff..1c8ea82ac 100644 --- a/wp_mobile_cache/src/repository/media.rs +++ b/wp_mobile_cache/src/repository/media.rs @@ -17,7 +17,8 @@ use rusqlite::{OptionalExtension, Row}; use std::{collections::HashMap, marker::PhantomData, sync::Arc}; use wp_api::{ media::{ - MediaCaptionWithEditContext, MediaDescriptionWithEditContext, MediaId, MediaWithEditContext, + MediaCaptionWithEditContext, MediaDescriptionWithEditContext, MediaId, MediaTypeWrapper, + MediaWithEditContext, }, posts::{PostGuidWithEditContext, PostTitleWithEditContext}, prelude::WpGmtDateTime, @@ -317,7 +318,7 @@ impl MediaContext for EditContext { raw: row.get_column(DescriptionRaw)?, rendered: row.get_column(DescriptionRendered)?, }, - media_type: parse_enum(row, MediaType)?, + media_type: Arc::new(parse_enum::(row, MediaType)?), mime_type: row.get_column(MimeType)?, media_details, post_id: get_optional_id(row, PostId)?, diff --git a/wp_mobile_cache/src/test_fixtures/media.rs b/wp_mobile_cache/src/test_fixtures/media.rs index e58f7105c..ce4f51ed0 100644 --- a/wp_mobile_cache/src/test_fixtures/media.rs +++ b/wp_mobile_cache/src/test_fixtures/media.rs @@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicI64, Ordering}; use wp_api::{ media::{ MediaCaptionWithEditContext, MediaDescriptionWithEditContext, MediaDetails, MediaId, - MediaStatus, MediaType, MediaWithEditContext, + MediaStatus, MediaType, MediaTypeWrapper, MediaWithEditContext, }, posts::{ PostCommentStatus, PostGuidWithEditContext, PostId, PostPingStatus, @@ -104,7 +104,7 @@ impl MediaBuilder { /// Set the media type. pub fn with_media_type(mut self, media_type: MediaType) -> Self { - self.media.media_type = media_type; + self.media.media_type = Arc::new(MediaTypeWrapper::from(media_type)); self } @@ -161,7 +161,7 @@ fn create_minimal_media() -> MediaWithEditContext { raw: String::new(), rendered: String::new(), }, - media_type: MediaType::File, + media_type: Arc::new(MediaTypeWrapper::from(MediaType::File)), mime_type: "application/octet-stream".into(), media_details: Arc::new(MediaDetails { payload: serde_json::value::RawValue::from_string("{}".into()).unwrap(), @@ -177,7 +177,7 @@ fn create_full_media() -> MediaWithEditContext { m.password = Some("secret".into()); m.post_id = Some(PostId(100)); m.alt_text = "alt text".into(); - m.media_type = MediaType::Image; + m.media_type = Arc::new(MediaTypeWrapper::from(MediaType::Image)); m.mime_type = "image/jpeg".into(); m.caption = MediaCaptionWithEditContext { raw: "caption raw".into(),