diff --git a/wp_api/src/menu_locations.rs b/wp_api/src/menu_locations.rs index 3ac2daa7d..79335bd42 100644 --- a/wp_api/src/menu_locations.rs +++ b/wp_api/src/menu_locations.rs @@ -8,7 +8,6 @@ wp_content_string_id!(MenuLocation); #[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] #[serde(transparent)] pub struct SparseMenuLocationsResponse { - #[serde(flatten)] #[WpContext(edit, embed, view)] #[WpContextualField] pub locations: Option>, diff --git a/wp_api/src/post_statuses.rs b/wp_api/src/post_statuses.rs index cf88f1baf..c2726e9d0 100644 --- a/wp_api/src/post_statuses.rs +++ b/wp_api/src/post_statuses.rs @@ -5,7 +5,6 @@ use wp_contextual::WpContextual; #[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] #[serde(transparent)] pub struct SparsePostStatusesResponse { - #[serde(flatten)] #[WpContext(edit, embed, view)] #[WpContextualField] pub post_statuses: Option>, diff --git a/wp_api/src/post_types.rs b/wp_api/src/post_types.rs index 5ca327d76..95026d10a 100644 --- a/wp_api/src/post_types.rs +++ b/wp_api/src/post_types.rs @@ -60,7 +60,6 @@ impl PostTypeDetailsWithEditContext { #[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] #[serde(transparent)] pub struct SparsePostTypesResponse { - #[serde(flatten)] #[WpContext(edit, embed, view)] #[WpContextualField] pub post_types: Option>, @@ -103,8 +102,6 @@ pub struct SparsePostTypeDetails { #[serde(transparent)] pub struct PostTypeSupportsMap { #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] - #[serde(flatten)] - #[serde(rename = "supports")] pub map: HashMap, } @@ -224,4 +221,14 @@ mod test { serde_json::from_str(data).expect("Failed to parse post types response"); assert_eq!(parsed.post_types.len(), 2); } + + #[test] + fn test_post_type_supports_map_from_empty_array() { + // WordPress returns `[]` instead of `{}` when a post type has no + // supported features (e.g. `register_post_type('foo', ['supports' => false])`). + let json = r#"[]"#; + let supports: PostTypeSupportsMap = + serde_json::from_str(json).expect("Should handle empty array from WordPress API"); + assert!(supports.map.is_empty()); + } } diff --git a/wp_api/src/taxonomies.rs b/wp_api/src/taxonomies.rs index 40f25de2d..c3696338a 100644 --- a/wp_api/src/taxonomies.rs +++ b/wp_api/src/taxonomies.rs @@ -120,7 +120,6 @@ pub struct TaxonomyListParams { #[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] #[serde(transparent)] pub struct SparseTaxonomyTypesResponse { - #[serde(flatten)] #[WpContext(edit, embed, view)] #[WpContextualField] pub taxonomy_types: Option>, diff --git a/wp_api/src/users.rs b/wp_api/src/users.rs index 2d235a0e2..320f9fb5e 100644 --- a/wp_api/src/users.rs +++ b/wp_api/src/users.rs @@ -264,8 +264,6 @@ impl_as_query_value_from_to_string!(UserCapability); #[serde(transparent)] pub struct UserCapabilitiesMap { #[serde(deserialize_with = "wp_serde_helper::deserialize_empty_array_or_hashmap")] - #[serde(flatten)] - #[serde(rename = "capabilities")] pub map: HashMap, } @@ -643,4 +641,12 @@ mod tests { fn test_user_role_from_str(#[case] role: UserRole, #[case] expected_str: &str) { assert_eq!(UserRole::from_str(expected_str), Ok(role)); } + + #[test] + fn test_user_capabilities_map_from_empty_array() { + let json = r#"[]"#; + let caps: UserCapabilitiesMap = + serde_json::from_str(json).expect("Should handle empty array from WordPress API"); + assert!(caps.map.is_empty()); + } } diff --git a/wp_api_integration_tests/tests/test_navigations_mut.rs b/wp_api_integration_tests/tests/test_navigations_mut.rs index 81dc0d542..faf091504 100644 --- a/wp_api_integration_tests/tests/test_navigations_mut.rs +++ b/wp_api_integration_tests/tests/test_navigations_mut.rs @@ -199,8 +199,6 @@ where } mod macro_helper { - use super::*; - macro_rules! generate_update_test { ($ident:ident, $field:ident, $new_value:expr, $assertion:expr) => { paste::paste! { diff --git a/wp_contextual/src/wp_contextual.rs b/wp_contextual/src/wp_contextual.rs index 42704c401..462644748 100644 --- a/wp_contextual/src/wp_contextual.rs +++ b/wp_contextual/src/wp_contextual.rs @@ -32,6 +32,15 @@ pub fn wp_contextual(ast: DeriveInput) -> Result { // Check if PartialEq should be derived let should_derive_partial_eq = !has_dont_derive_partial_eq_attr(&ast.attrs); + // Collect serde struct-level attributes (e.g. #[serde(transparent)]) to propagate + // to generated types. Field-level serde attributes are already propagated as + // ExternalAttr; struct-level ones need explicit forwarding. + let serde_struct_attrs: Vec<&syn::Attribute> = ast + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde")) + .collect(); + let contextual_token_streams = WpContextAttr::iter().map(|current_context| { let generate_type = |is_sparse, ident, generated_fields: &Vec| { if !generated_fields.is_empty() { @@ -60,6 +69,7 @@ pub fn wp_contextual(ast: DeriveInput) -> Result { let fields_to_add = generated_fields.iter().map(|f| &f.field); quote! { #[derive(#(#derives),*)] + #(#serde_struct_attrs)* pub struct #ident { #(#fields_to_add,)* } diff --git a/wp_derive/src/wp_deserialize.rs b/wp_derive/src/wp_deserialize.rs index abcb873f0..ff69d4511 100644 --- a/wp_derive/src/wp_deserialize.rs +++ b/wp_derive/src/wp_deserialize.rs @@ -11,13 +11,20 @@ use syn::{ pub(crate) fn derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let parsed_struct = parse_macro_input!(input as ParsedStruct); - TokenStream::from_iter([ - parsed_struct.generate_cloned_type_with_new_name(), - parsed_struct.generate_all_fields_set_to_none_implementation(), - parsed_struct.generate_from_implementation_for_cloned_type(), - parsed_struct.generate_custom_deserializer(), - ]) - .into() + if parsed_struct.has_serde_transparent() { + match parsed_struct.generate_transparent_deserializer() { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } + } else { + TokenStream::from_iter([ + parsed_struct.generate_cloned_type_with_new_name(), + parsed_struct.generate_all_fields_set_to_none_implementation(), + parsed_struct.generate_from_implementation_for_cloned_type(), + parsed_struct.generate_custom_deserializer(), + ]) + .into() + } } #[derive(Debug)] @@ -32,6 +39,48 @@ impl ParsedStruct { format_ident!("DeserializeHelper{}", self.struct_ident.to_string()) } + fn has_serde_transparent(&self) -> bool { + self.attrs.iter().any(|attr| { + if let syn::Meta::List(meta_list) = &attr.meta + && let Some(ident) = meta_list.path.get_ident() + && *ident == "serde" + { + meta_list.tokens.clone().into_iter().any(|token| { + matches!(token, proc_macro2::TokenTree::Ident(ident) if ident == "transparent") + }) + } else { + false + } + }) + } + + /// Extracts the inner type `T` from a field typed `Option`. + fn extract_option_inner_type(field: &Field) -> Option<&syn::Type> { + if let syn::Type::Path(type_path) = &field.ty + && let Some(first_segment) = type_path.path.segments.first() + && first_segment.ident == "Option" + && let syn::PathArguments::AngleBracketed(args) = &first_segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + return Some(inner_ty); + } + None + } + + /// Checks whether a type's last path segment is `HashMap`. + /// Handles both `HashMap` and `std::collections::HashMap`. + fn is_hashmap(ty: &syn::Type) -> bool { + if let syn::Type::Path(type_path) = ty { + type_path + .path + .segments + .last() + .is_some_and(|seg| seg.ident == "HashMap") + } else { + false + } + } + fn generate_cloned_type_with_new_name(&self) -> TokenStream { let attrs = &self.attrs; let fields = &self.fields; @@ -110,6 +159,89 @@ impl ParsedStruct { } } } + + /// Generates a custom `Deserialize` impl for `#[serde(transparent)]` structs. + /// + /// Standard `WpDeserialize` uses `DeserializeEmptyVecOrT`, but + /// `#[serde(transparent)]` on the helper causes `Option::deserialize` to call + /// `deserialize_option` on a `MapAccessDeserializer`, which can't distinguish + /// null from present. Instead, we generate an inline visitor that resolves the + /// `Option` directly at the `visit_map`/`visit_seq` dispatch point. + /// + /// Only supports `Option>` fields — the generated visitor only + /// handles `visit_map` (for map inputs) and `visit_seq` (for empty arrays). + fn generate_transparent_deserializer(&self) -> syn::Result { + let struct_ident = &self.struct_ident; + let field = self + .fields + .first() + .expect("transparent struct must have exactly one field"); + let field_ident = field + .ident + .as_ref() + .expect("transparent field must have an ident"); + let inner_type = + Self::extract_option_inner_type(field).expect("transparent field must be Option"); + + if !Self::is_hashmap(inner_type) { + return Err( + WpDeserializeParseError::TransparentRequiresHashMap.into_syn_error(field.ty.span()) + ); + } + + let visitor_ident = format_ident!("{}TransparentVisitor", self.struct_ident); + + Ok(quote! { + struct #visitor_ident; + + impl<'de> serde::de::Visitor<'de> for #visitor_ident { + type Value = #struct_ident; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("empty array or map") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + if serde::de::SeqAccess::next_element::(&mut seq)?.is_none() { + Ok(#struct_ident { #field_ident: None }) + } else { + Err(serde::de::Error::invalid_type( + serde::de::Unexpected::Seq, + &self, + )) + } + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + // Safe to wrap in Some: deserialize_any dispatches visit_map only + // for actual map inputs, never for null. The null-vs-present + // decision is already made at the deserialize_any call site. + serde::Deserialize::deserialize( + serde::de::value::MapAccessDeserializer::new(map), + ) + .map(|inner: #inner_type| #struct_ident { #field_ident: Some(inner) }) + } + } + + impl<'de> serde::Deserialize<'de> for #struct_ident { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Uses deserialize_any so the format-level deserializer (e.g. serde_json) + // dispatches to visit_map/visit_seq based on the actual input token. + // This is what makes the Some-wrapping in visit_map safe — see comment there. + deserializer.deserialize_any(#visitor_ident) + } + } + }) + } } impl ParsedStruct { @@ -141,36 +273,11 @@ impl ParsedStruct { Ok(fields) } - - // Fails if attributes include `#[serde(transparent)]` - fn parse_outer(input: ParseStream) -> syn::Result> { - let attrs = Attribute::parse_outer(input)?; - for attr in &attrs { - if let syn::Meta::List(meta_list) = &attr.meta - && let Some(ident) = meta_list.path.get_ident() - { - if *ident != "serde" { - continue; - } - - if let Some(proc_macro2::TokenTree::Ident(first_token_ident)) = - meta_list.tokens.clone().into_iter().next() - && first_token_ident.to_string().as_str() == "transparent" - { - return Err( - WpDeserializeParseError::SerdeTransparentAttributeNotSupported - .into_syn_error(first_token_ident.span()), - ); - } - } - } - Ok(attrs) - } } impl Parse for ParsedStruct { fn parse(input: ParseStream) -> syn::Result { - let attrs = Self::parse_outer(input)?; + let attrs = Attribute::parse_outer(input)?; let _vis: syn::Visibility = input.parse()?; let _struct_token: Token![struct] = input.parse()?; @@ -194,8 +301,12 @@ enum WpDeserializeParseError { original_type )] NonOptionalField { original_type: String }, - #[error("{}", SERDE_TRANSPARENT_PARSING_ERROR)] - SerdeTransparentAttributeNotSupported, + #[error( + "`WpDeserialize` with `#[serde(transparent)]` only supports `Option>` fields. \ + The generated deserializer uses `visit_map` to deserialize the inner type from a map input, \ + which only works for map-deserializable types like `HashMap`." + )] + TransparentRequiresHashMap, } impl WpDeserializeParseError { @@ -203,10 +314,3 @@ impl WpDeserializeParseError { syn::Error::new(span, self.to_string()) } } - -const SERDE_TRANSPARENT_PARSING_ERROR: &str = r#"`#[serde(transparent)]` attribute is not supported. - -`wp_derive::WpDeserialize` and `#[serde(transparent)]` can't be combined. However, you can use `wp_serde_helper::DeserializeEmptyVecOrT` to manually replicate the same behaviour. - -Here is an example of how to do this: -https://github.com/Automattic/wordpress-rs/pull/532/commits/1027e6ed6c8bd0e4cd9aec5ec0595f64f5e925b7#diff-139286995fa3539fe509c87c0b832e974c87c687d64330c6a9c0bd117cdf54f7"#; diff --git a/wp_derive/tests/wp_deserialize/fail/serde_transparent.stderr b/wp_derive/tests/wp_deserialize/fail/serde_transparent.stderr deleted file mode 100644 index 57e0f215a..000000000 --- a/wp_derive/tests/wp_deserialize/fail/serde_transparent.stderr +++ /dev/null @@ -1,10 +0,0 @@ -error: `#[serde(transparent)]` attribute is not supported. - - `wp_derive::WpDeserialize` and `#[serde(transparent)]` can't be combined. However, you can use `wp_serde_helper::DeserializeEmptyVecOrT` to manually replicate the same behaviour. - - Here is an example of how to do this: - https://github.com/Automattic/wordpress-rs/pull/532/commits/1027e6ed6c8bd0e4cd9aec5ec0595f64f5e925b7#diff-139286995fa3539fe509c87c0b832e974c87c687d64330c6a9c0bd117cdf54f7 - --> tests/wp_deserialize/fail/serde_transparent.rs:5:9 - | -5 | #[serde(transparent)] - | ^^^^^^^^^^^ diff --git a/wp_derive/tests/wp_deserialize/fail/serde_transparent.rs b/wp_derive/tests/wp_deserialize/fail/serde_transparent_non_hashmap.rs similarity index 100% rename from wp_derive/tests/wp_deserialize/fail/serde_transparent.rs rename to wp_derive/tests/wp_deserialize/fail/serde_transparent_non_hashmap.rs diff --git a/wp_derive/tests/wp_deserialize/fail/serde_transparent_non_hashmap.stderr b/wp_derive/tests/wp_deserialize/fail/serde_transparent_non_hashmap.stderr new file mode 100644 index 000000000..f6741ea99 --- /dev/null +++ b/wp_derive/tests/wp_deserialize/fail/serde_transparent_non_hashmap.stderr @@ -0,0 +1,5 @@ +error: `WpDeserialize` with `#[serde(transparent)]` only supports `Option>` fields. The generated deserializer uses `visit_map` to deserialize the inner type from a map input, which only works for map-deserializable types like `HashMap`. + --> tests/wp_deserialize/fail/serde_transparent_non_hashmap.rs:7:14 + | +7 | pub bar: Option, + | ^^^^^^ diff --git a/wp_derive/tests/wp_deserialize/pass/serde_transparent.rs b/wp_derive/tests/wp_deserialize/pass/serde_transparent.rs new file mode 100644 index 000000000..bf1428e09 --- /dev/null +++ b/wp_derive/tests/wp_deserialize/pass/serde_transparent.rs @@ -0,0 +1,24 @@ +use serde::Serialize; +use std::collections::HashMap; +use wp_derive::WpDeserialize; + +const EMPTY_ARRAY: &str = "[]"; + +#[derive(Serialize, WpDeserialize)] +#[serde(transparent)] +pub struct MapWrapper { + pub items: Option>, +} + +fn main() { + // Empty array → None + let result = serde_json::from_str::(EMPTY_ARRAY) + .expect("MapWrapper should handle empty array"); + assert!(result.items.is_none()); + + // JSON object → Some(HashMap) + let result = serde_json::from_str::(r#"{"key": "value"}"#) + .expect("MapWrapper should handle JSON object"); + let items = result.items.expect("items should be Some"); + assert_eq!(items.get("key"), Some(&"value".to_string())); +}