Skip to content

Commit

Permalink
Avoid emitting a #[schemars] annotation when one already exists
Browse files Browse the repository at this point in the history
The #[schemars] annotation emitted by #[serde_as] will cause errors if
the user has already added an existing #[schemars(with = ...)]
annotation. This ends up being rather annoying since there's no way to
work around the error other than to manually expand what #[serde_as]
does.

This commit fixes the issue by making #[serde_as] avoid emitting a
schemars attribute if there is an existing one of the field that
specifies any one of
- #[schemars(with = "...")]
- #[schemars(serialize_with = "...")]
- #[schemars(deserialize_with = "...")]

The actual code is a bit more involved (e.g. serialize_as doesn't
conflict with #[schemars(deserialize_with = "...")]) but that's the gist
of it.

I have also included a test case to validate that this works as
expected.
  • Loading branch information
swlynch99 committed Jan 22, 2024
1 parent 6ecde3c commit c09f33b
Show file tree
Hide file tree
Showing 3 changed files with 143 additions and 28 deletions.
20 changes: 20 additions & 0 deletions serde_with/tests/schemars_0_8.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@ fn schemars_basic() {
expected.assert_eq(&schema);
}

#[test]
fn schemars_custom_with() {
#[serde_as]
#[derive(JsonSchema, Serialize)]
struct Test {
#[serde_as(as = "DisplayFromStr")]
#[schemars(with = "i32")]
custom: i32,

#[serde_as(as = "DisplayFromStr")]
#[cfg_attr(any(), schemars(with = "i32"))]
with_disabled: i32,
}

check_matches_schema::<Test>(&json!({
"custom": 5,
"with_disabled": "5",
}));
}

mod test_std {
use super::*;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
Expand Down
31 changes: 26 additions & 5 deletions serde_with_macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -782,10 +782,19 @@ fn serde_as_add_attr_to_field(
field.attrs.push(attr);

if let Some(cfg) = schemars_config.cfg_expr() {
let with_cfg = crate::utils::schemars_with_attr_if(
&field.attrs,
&["with", "serialize_with", "deserialize_with"],
)?;
let attr_inner_tokens =
quote!(#serde_with_crate_path::Schema::<#type_original, #replacement_type>)
.to_string();
let attr = parse_quote!(#[cfg_attr(#cfg, schemars(with = #attr_inner_tokens))]);
let attr = parse_quote! {
#[cfg_attr(
all(#cfg, not(#with_cfg)),
schemars(with = #attr_inner_tokens))
]
};
field.attrs.push(attr);
}
}
Expand All @@ -800,11 +809,17 @@ fn serde_as_add_attr_to_field(
field.attrs.push(attr);

if let Some(cfg) = schemars_config.cfg_expr() {
let with_cfg =
crate::utils::schemars_with_attr_if(&field.attrs, &["with", "deserialize_with"])?;
let attr_inner_tokens =
quote!(#serde_with_crate_path::Schema::<#type_original, #replacement_type>::deserialize)
.to_string();
let attr =
parse_quote!(#[cfg_attr(#cfg, schemars(deserialize_with = #attr_inner_tokens))]);
let attr = parse_quote! {
#[cfg_attr(
all(#cfg, not(#with_cfg)),
schemars(deserialize_with = #attr_inner_tokens))
]
};
field.attrs.push(attr);
}
}
Expand All @@ -816,11 +831,17 @@ fn serde_as_add_attr_to_field(
field.attrs.push(attr);

if let Some(cfg) = schemars_config.cfg_expr() {
let with_cfg =
crate::utils::schemars_with_attr_if(&field.attrs, &["with", "serialize_with"])?;
let attr_inner_tokens =
quote!(#serde_with_crate_path::Schema::<#type_original, #replacement_type>::serialize)
.to_string();
let attr =
parse_quote!(#[cfg_attr(#cfg, schemars(serialize_with = #attr_inner_tokens))]);
let attr = parse_quote! {
#[cfg_attr(
all(#cfg, not(#with_cfg)),
schemars(serialize_with = #attr_inner_tokens))
]
};
field.attrs.push(attr);
}
}
Expand Down
120 changes: 97 additions & 23 deletions serde_with_macros/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
use core::iter::Iterator;
use darling::FromDeriveInput;
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use proc_macro2::{TokenStream as TokenStream2, TokenTree};
use quote::ToTokens;
use syn::{parse_quote, punctuated::Punctuated, Error, Generics, Path, TypeGenerics};
use std::collections::BTreeSet;
use syn::{
ext::IdentExt, parse::Parse, parse_quote, punctuated::Punctuated, Error, Generics, Path,
TypeGenerics,
};

/// Merge multiple [`syn::Error`] into one.
pub(crate) trait IteratorExt {
Expand Down Expand Up @@ -76,30 +80,30 @@ impl<'a> ToTokens for DeImplGenerics<'a> {
}
}

/// Determine if there is a `#[derive(JsonSchema)]` on this struct.
pub(crate) fn has_derive_jsonschema(input: TokenStream) -> SchemaFieldConfig {
/// Represents the macro body of a `#[cfg_attr]` attribute.
///
/// ```text
/// #[cfg_attr(feature = "things", derive(Macro))]
/// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/// ```
struct CfgAttr {
cfg: syn::Expr,
_comma: syn::Token![,],
meta: syn::Meta,
}
/// Represents the macro body of a `#[cfg_attr]` attribute.
///
/// ```text
/// #[cfg_attr(feature = "things", derive(Macro))]
/// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/// ```
struct CfgAttr {
cfg: syn::Expr,
_comma: syn::Token![,],
meta: syn::Meta,
}

impl syn::parse::Parse for CfgAttr {
fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
Ok(Self {
cfg: input.parse()?,
_comma: input.parse()?,
meta: input.parse()?,
})
}
impl Parse for CfgAttr {
fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
Ok(Self {
cfg: input.parse()?,
_comma: input.parse()?,
meta: input.parse()?,
})
}
}

/// Determine if there is a `#[derive(JsonSchema)]` on this struct.
pub(crate) fn has_derive_jsonschema(input: TokenStream) -> SchemaFieldConfig {
fn parse_derive_args(
input: syn::parse::ParseStream<'_>,
) -> syn::Result<Punctuated<Path, syn::Token![,]>> {
Expand Down Expand Up @@ -180,3 +184,73 @@ impl SchemaFieldConfig {
}
}
}

struct SchemarsAttr {
args: BTreeSet<String>,
}

impl Parse for SchemarsAttr {
fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
let mut args = BTreeSet::new();

while !input.is_empty() {
let arg = syn::Ident::parse_any(input)?;
let _eq: syn::Token![=] = input.parse()?;

args.insert(arg.to_string());

// Don't parse the argument value, just advance until we hit the end or a comma.
input.step(|cursor| {
let mut rest = *cursor;
loop {
match rest.token_tree() {
Some((TokenTree::Punct(punct), next)) if punct.as_char() == ',' => {
return Ok(((), next))
}
Some((_, next)) => rest = next,
None => return Ok(((), rest)),
}
}
})?;
}

Ok(Self { args })
}
}

/// Get a `#[cfg]` expression under which this field has a `#[schemars]` attribute
/// with a `with = ...` argument.
pub(crate) fn schemars_with_attr_if(
attrs: &[syn::Attribute],
filter: &[&str],
) -> syn::Result<syn::Expr> {
let mut conditions = Vec::new();

for attr in attrs {
let path = attr.path();

let nested;
let (cfg, meta) = match () {
_ if path.is_ident("cfg_attr") => {
let cfg_attr = attr.parse_args_with(CfgAttr::parse)?;
nested = cfg_attr.meta;

(cfg_attr.cfg, &nested)
}
_ if path.is_ident("schemars") => (syn::parse_quote!(all()), &attr.meta),
_ => continue,
};

let list = meta.require_list()?;
let schemars: SchemarsAttr = syn::parse2(list.tokens.clone())?;
let args = &schemars.args;

if !filter.iter().copied().any(|item| args.contains(item)) {
continue;
}

conditions.push(cfg);
}

Ok(syn::parse_quote!(any(#( #conditions, )*)))
}

0 comments on commit c09f33b

Please sign in to comment.