Skip to content
Merged
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
1 change: 0 additions & 1 deletion wp_api/src/menu_locations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<MenuLocation, SparseMenuLocation>>,
Expand Down
1 change: 0 additions & 1 deletion wp_api/src/post_statuses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<PostStatusSlug, SparsePostStatus>>,
Expand Down
13 changes: 10 additions & 3 deletions wp_api/src/post_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<PostType, SparsePostTypeDetails>>,
Expand Down Expand Up @@ -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<PostTypeSupports, JsonValue>,
}

Expand Down Expand Up @@ -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());
}
}
1 change: 0 additions & 1 deletion wp_api/src/taxonomies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<TaxonomyType, SparseTaxonomyTypeDetails>>,
Expand Down
10 changes: 8 additions & 2 deletions wp_api/src/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserCapability, JsonValue>,
}

Expand Down Expand Up @@ -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());
}
}
2 changes: 0 additions & 2 deletions wp_api_integration_tests/tests/test_navigations_mut.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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! {
Expand Down
10 changes: 10 additions & 0 deletions wp_contextual/src/wp_contextual.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ pub fn wp_contextual(ast: DeriveInput) -> Result<TokenStream, syn::Error> {
// 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<GeneratedContextualField>| {
if !generated_fields.is_empty() {
Expand Down Expand Up @@ -60,6 +69,7 @@ pub fn wp_contextual(ast: DeriveInput) -> Result<TokenStream, syn::Error> {
let fields_to_add = generated_fields.iter().map(|f| &f.field);
quote! {
#[derive(#(#derives),*)]
#(#serde_struct_attrs)*
pub struct #ident {
#(#fields_to_add,)*
}
Expand Down
188 changes: 146 additions & 42 deletions wp_derive/src/wp_deserialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<T>`.
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<K, V>` and `std::collections::HashMap<K, V>`.
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;
Expand Down Expand Up @@ -110,6 +159,89 @@ impl ParsedStruct {
}
}
}

/// Generates a custom `Deserialize` impl for `#[serde(transparent)]` structs.
///
/// Standard `WpDeserialize` uses `DeserializeEmptyVecOrT<DeserializeHelper>`, but
/// `#[serde(transparent)]` on the helper causes `Option<T>::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<HashMap<K, V>>` fields — the generated visitor only
/// handles `visit_map` (for map inputs) and `visit_seq` (for empty arrays).
fn generate_transparent_deserializer(&self) -> syn::Result<TokenStream> {
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<T>");

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<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
if serde::de::SeqAccess::next_element::<Self::Value>(&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<A>(self, map: A) -> Result<Self::Value, A::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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 {
Expand Down Expand Up @@ -141,36 +273,11 @@ impl ParsedStruct {

Ok(fields)
}

// Fails if attributes include `#[serde(transparent)]`
fn parse_outer(input: ParseStream) -> syn::Result<Vec<Attribute>> {
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<Self> {
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()?;
Expand All @@ -194,19 +301,16 @@ enum WpDeserializeParseError {
original_type
)]
NonOptionalField { original_type: String },
#[error("{}", SERDE_TRANSPARENT_PARSING_ERROR)]
SerdeTransparentAttributeNotSupported,
#[error(
"`WpDeserialize` with `#[serde(transparent)]` only supports `Option<HashMap<K, V>>` 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 {
fn into_syn_error(self, span: proc_macro2::Span) -> syn::Error {
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"#;
10 changes: 0 additions & 10 deletions wp_derive/tests/wp_deserialize/fail/serde_transparent.stderr

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: `WpDeserialize` with `#[serde(transparent)]` only supports `Option<HashMap<K, V>>` 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<String>,
| ^^^^^^
24 changes: 24 additions & 0 deletions wp_derive/tests/wp_deserialize/pass/serde_transparent.rs
Original file line number Diff line number Diff line change
@@ -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<HashMap<String, String>>,
}

fn main() {
// Empty array → None
let result = serde_json::from_str::<MapWrapper>(EMPTY_ARRAY)
.expect("MapWrapper should handle empty array");
assert!(result.items.is_none());

// JSON object → Some(HashMap)
let result = serde_json::from_str::<MapWrapper>(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()));
}