diff --git a/.travis.yml b/.travis.yml index c4d15f59..e7305726 100644 --- a/.travis.yml +++ b/.travis.yml @@ -70,7 +70,7 @@ before_script: script: - set -e - | - for FEATURES_COMMAND in "--no-default-features" "" "--features=chrono" "--features=json" "--all-features" + for FEATURES_COMMAND in "--no-default-features" "" "--features=chrono" "--features=json" "--features=macros" "--all-features" do cargo build --verbose --all ${FEATURES_COMMAND} # skip this step if clippy is not available, e.g., bad nightly diff --git a/CHANGELOG.md b/CHANGELOG.md index 767f4d1e..96b0dcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +* Add `skip_serializing_none` attribute, which adds `#[serde(skip_serializing_if = "Option::is_none")]` for each Option in a struct. + This is helpfull for APIs which have many optional fields. + The effect of can be negated by adding `serialize_always` on those fields, which should always be serialized. + Existing `skip_serializing_if` will never be modified and those fields keep their behavior. + ## [1.2.0] ### Added diff --git a/Cargo.toml b/Cargo.toml index b7796345..449f0419 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,8 @@ +[workspace] +members = [ + "serde_with_macros", +] + [package] name = "serde_with" version = "1.2.0" @@ -26,12 +31,15 @@ codecov = { repository = "jonasbb/serde_with", branch = "master", service = "git maintenance = { status = "actively-developed" } [features] +default = [ "macros" ] json = [ "serde_json" ] +macros = [ "serde_with_macros" ] [dependencies] chrono = { version = "0.4.1", features = [ "serde" ], optional = true } serde = "1.0.75" serde_json = { version = "1.0.1", optional = true } +serde_with_macros = { path = "./serde_with_macros", version = "1.0.0", optional = true} [dev-dependencies] fnv = "1.0.6" diff --git a/serde_with_macros/Cargo.toml b/serde_with_macros/Cargo.toml new file mode 100644 index 00000000..e482cd3a --- /dev/null +++ b/serde_with_macros/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "serde_with_macros" +version = "1.0.0" +authors = ["jonasbb"] + +description = "proc-macro library for serde_with" +documentation = "https://docs.rs/serde_with_macros/" +repository = "https://github.com/jonasbb/serde_with/serde_with_macros" +readme = "README.md" +keywords = ["serde", "utilities", "serialization", "deserialization"] +license = "MIT/Apache-2.0" + +[lib] +proc-macro = true + +[badges] +travis-ci = { repository = "jonasbb/serde_with", branch = "master" } +codecov = { repository = "jonasbb/serde_with", branch = "master", service = "github" } +maintenance = { status = "actively-developed" } + +[dependencies] +proc-macro2 = { version = "0.4.27" } +quote = "0.6.11" + +[dependencies.syn] +version = "0.15.29" +default-features = false +features = [ + "extra-traits", # Only for debugging + "full", + "parsing", + "printing", + "proc-macro", +] + +[dev-dependencies] +compiletest_rs = { version = "0.3.19", features = [ "stable" ] } +pretty_assertions = "0.5.1" +serde = { version = "1.0.75", features = [ "derive" ] } +serde_json = "1.0.25" +version-sync = "0.7.0" + +[package.metadata.docs.rs] +all-features = true diff --git a/serde_with_macros/LICENSE-APACHE b/serde_with_macros/LICENSE-APACHE new file mode 120000 index 00000000..965b606f --- /dev/null +++ b/serde_with_macros/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/serde_with_macros/LICENSE-MIT b/serde_with_macros/LICENSE-MIT new file mode 120000 index 00000000..76219eb7 --- /dev/null +++ b/serde_with_macros/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/serde_with_macros/README.md b/serde_with_macros/README.md new file mode 120000 index 00000000..32d46ee8 --- /dev/null +++ b/serde_with_macros/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/serde_with_macros/src/lib.rs b/serde_with_macros/src/lib.rs new file mode 100644 index 00000000..1d51a93a --- /dev/null +++ b/serde_with_macros/src/lib.rs @@ -0,0 +1,278 @@ +#![deny( + missing_debug_implementations, + missing_copy_implementations, + trivial_casts, + trivial_numeric_casts, + unused_extern_crates, + unused_import_braces, + unused_qualifications, + variant_size_differences +)] +#![cfg_attr(feature = "cargo-clippy", allow(renamed_and_removed_lints))] +#![doc(html_root_url = "https://docs.rs/serde_with_macros/1.0.0")] + +//! proc-macro extensions for [`serde_with`] +//! +//! This crate should not be used alone, but through the [`serde_with`] crate. +//! +//! [`serde_with`]: https://crates.io/crates/serde_with/ + +extern crate proc_macro; +extern crate proc_macro2; +extern crate quote; +extern crate syn; + +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::quote; +use syn::{ + parse::Parser, Attribute, Error, Field, Fields, ItemEnum, ItemStruct, Meta, NestedMeta, Path, + Type, +}; + +/// Add `skip_serializing_if` annotations to [`Option`] fields. +/// +/// The attribute can be added to structs and enums. +/// +/// # Example +/// +/// JSON APIs sometimes have many optional values. +/// Missing values should not be serialized, to keep the serialized format smaller. +/// Such a data type might look like: +/// +/// ```rust +/// # extern crate serde; +/// # use serde::Serialize; +/// # +/// #[derive(Serialize)] +/// struct Data { +/// #[serde(skip_serializing_if = "Option::is_none")] +/// a: Option, +/// #[serde(skip_serializing_if = "Option::is_none")] +/// b: Option, +/// #[serde(skip_serializing_if = "Option::is_none")] +/// c: Option, +/// #[serde(skip_serializing_if = "Option::is_none")] +/// d: Option, +/// } +/// ``` +/// +/// The `skip_serializing_if` annotation is repetitive and harms readability. +/// Instead the same struct can be written as: +/// +/// ```rust +/// # extern crate serde; +/// # extern crate serde_with_macros; +/// # +/// # use serde::Serialize; +/// # use serde_with_macros::skip_serializing_none; +/// #[skip_serializing_none] +/// #[derive(Serialize)] +/// struct Data { +/// a: Option, +/// b: Option, +/// c: Option, +/// d: Option, +/// } +/// ``` +/// +/// Existing `skip_serializing_if` annotations will not be altered. +/// +/// If some values should always be serialized, then the `serialize_always` can be used. +/// +/// # Limitations +/// +/// The `serialize_always` cannot be used together with a manual `skip_serializing_if` annotations, as these conflict in their meaning. +/// A compile error will be generated if this occurs. +/// +/// The `skip_serializing_none` only works if the type is called `Option`, `std::option::Option`, or `core::option::Option`. +/// Type aliasing on `Option` and giving it another name, will cause this field to be ignored. +/// This cannot be supported, as proc-macros run before type checking, thus it is not possible to determine if a type alias refers to an `Option`. +/// +/// ```rust,ignore +/// # extern crate serde; +/// # extern crate serde_with_macros; +/// # +/// # use serde::Serialize; +/// # use serde_with_macros::skip_serializing_none; +/// type MyOption = Option; +/// +/// #[skip_serializing_none] +/// #[derive(Serialize)] +/// struct Data { +/// a: MyOption, // This field will not be skipped +/// } +/// ``` +/// +/// Likewise, if you import a type and name it `Option`, the `skip_serializing_if` attributes will be added and compile errors will occur, if `Option::is_none` is not a valid function. +/// Here the function `Vec::is_none` does not exist and therefore the example fails to compile. +/// +/// ```rust,compile_fail +/// # extern crate serde; +/// # extern crate serde_with_macros; +/// # +/// # use serde::Serialize; +/// # use serde_with_macros::skip_serializing_none; +/// use std::vec::Vec as Option; +/// +/// #[skip_serializing_none] +/// #[derive(Serialize)] +/// struct Data { +/// a: Option, +/// } +/// ``` +/// +#[proc_macro_attribute] +pub fn skip_serializing_none(_args: TokenStream, input: TokenStream) -> TokenStream { + let res = match skip_serializing_none_do(input) { + Ok(res) => res, + Err(msg) => { + let span = Span::call_site(); + Error::new(span, msg).to_compile_error() + } + }; + TokenStream::from(res) +} + +fn skip_serializing_none_do(input: TokenStream) -> Result { + // For each field in the struct given by `input`, add the `skip_serializing_if` attribute, + // if and only if, it is of type `Option` + if let Ok(mut input) = syn::parse::(input.clone()) { + skip_serializing_none_handle_fields(&mut input.fields)?; + Ok(quote!(#input)) + } else if let Ok(mut input) = syn::parse::(input.clone()) { + input + .variants + .iter_mut() + .map(|variant| skip_serializing_none_handle_fields(&mut variant.fields)) + .collect::>()?; + Ok(quote!(#input)) + } else { + Err("The attribute can only be applied to struct or enum definitions.".into()) + } +} + +/// Return `true`, if the type path refers to `std::option::Option` +/// +/// Accepts +/// +/// * `Option` +/// * `std::option::Option`, with or without leading `::` +/// * `core::option::Option`, with or without leading `::` +fn is_std_option(path: &Path) -> bool { + (path.leading_colon.is_none() && path.segments.len() == 1 && path.segments[0].ident == "Option") + || (path.segments.len() == 3 + && (path.segments[0].ident == "std" || path.segments[0].ident == "core") + && path.segments[1].ident == "option" + && path.segments[2].ident == "Option") +} + +/// Determine if the `field` has an attribute with given `namespace` and `name` +/// +/// On the example of +/// `#[serde(skip_serializing_if = "Option::is_none")]` +// +/// * `serde` is the outermost path, here namespace +/// * it contains a Meta::List +/// * which contains in another Meta a Meta::NameValue +/// * with the name being `skip_serializing_if` +#[cfg_attr(feature = "cargo-clippy", allow(cmp_owned))] +fn field_has_attribute(field: &Field, namespace: &str, name: &str) -> bool { + // On the example of + // #[serde(skip_serializing_if = "Option::is_none")] + // + // `serde` is the outermost path, here namespace + // it contains a Meta::List + // which contains in another Meta a Meta::NameValue + // with the name being `skip_serializing_if` + + for attr in &field.attrs { + if attr.path.is_ident(namespace) { + // Ignore non parsable attributes, as these are not important for us + if let Ok(expr) = attr.parse_meta() { + if let Meta::List(expr) = expr { + for expr in expr.nested { + if let NestedMeta::Meta(Meta::NameValue(expr)) = expr { + if expr.ident.to_string() == name { + return true; + } + } + } + } + } + } + } + false +} + +/// Add the skip_serializing_if annotation to each field of the struct +fn skip_serializing_none_add_attr_to_field<'a>( + fields: impl IntoIterator, +) -> Result<(), String> { + fields.into_iter().map(|field| ->Result<(), String> { + if let Type::Path(path) = &field.ty.clone() { + if is_std_option(&path.path) { + let has_skip_serializing_if = + field_has_attribute(&field, "serde", "skip_serializing_if"); + + // Remove the `serialize_always` attribute + let mut has_always_attr = false; + field.attrs.retain(|attr| { + let has_attr = attr.path.is_ident("serialize_always"); + has_always_attr |= has_attr; + !has_attr + }); + + // Error on conflicting attributes + if has_always_attr && has_skip_serializing_if { + let mut msg = r#"The attributes `serialize_always` and `serde(skip_serializing_if = "...")` cannot be used on the same field"#.to_string(); + if let Some(ident) = &field.ident { + msg += ": `"; + msg += &ident.to_string(); + msg += "`"; + } + msg +="."; + return Err(msg); + } + + // Do nothing if `skip_serializing_if` or `serialize_always` is already present + if has_skip_serializing_if || has_always_attr { + return Ok(()); + } + + // Add the `skip_serializing_if` attribute + let attr_tokens = quote!( + #[serde(skip_serializing_if = "Option::is_none")] + ); + let parser = Attribute::parse_outer; + let attrs = parser + .parse2(attr_tokens) + .expect("Static attr tokens should not panic"); + field.attrs.extend(attrs); + } else { + // Warn on use of `serialize_always` on non-Option fields + let has_attr= field.attrs.iter().any(|attr| { + attr.path.is_ident("serialize_always") + }); + if has_attr { + return Err("`serialize_always` may only be used on fields of type `Option`.".into()); + } + } + } + Ok(()) + }).collect() +} + +/// Handle a single struct or a single enum variant +fn skip_serializing_none_handle_fields(fields: &mut Fields) -> Result<(), String> { + match fields { + // simple, no fields, do nothing + Fields::Unit => Ok(()), + Fields::Named(ref mut fields) => { + skip_serializing_none_add_attr_to_field(fields.named.iter_mut()) + } + Fields::Unnamed(ref mut fields) => { + skip_serializing_none_add_attr_to_field(fields.unnamed.iter_mut()) + } + } +} diff --git a/serde_with_macros/tests/compile-fail/skip-none-always.rs b/serde_with_macros/tests/compile-fail/skip-none-always.rs new file mode 100644 index 00000000..d282d5d5 --- /dev/null +++ b/serde_with_macros/tests/compile-fail/skip-none-always.rs @@ -0,0 +1,31 @@ +extern crate serde; +extern crate serde_with_macros; + +use serde::Serialize; +use serde_with_macros::skip_serializing_none; + +#[skip_serializing_none] +//~^ error: The attributes `serialize_always` and `serde(skip_serializing_if = "...")` cannot be used on the same field: `a`. +#[derive(Serialize)] +struct Data { + #[serialize_always] + #[serde(skip_serializing_if = "Option::is_none")] + a: Option, +} + +#[skip_serializing_none] +//~^ error: The attributes `serialize_always` and `serde(skip_serializing_if = "...")` cannot be used on the same field. +#[derive(Serialize)] +struct Data2 ( + #[serialize_always] + #[serde(skip_serializing_if = "Option::is_none")] + Option +); + +#[skip_serializing_none] +//~^ error: `serialize_always` may only be used on fields of type `Option`. +#[derive(Serialize)] +struct Data3 { + #[serialize_always] + a: char, +} diff --git a/serde_with_macros/tests/compile-fail/skip-none-not-struct.rs b/serde_with_macros/tests/compile-fail/skip-none-not-struct.rs new file mode 100644 index 00000000..72d4a2d7 --- /dev/null +++ b/serde_with_macros/tests/compile-fail/skip-none-not-struct.rs @@ -0,0 +1,7 @@ +extern crate serde_with_macros; + +use serde_with_macros::skip_serializing_none; + +#[skip_serializing_none] +//~^ error: The attribute can only be applied to struct or enum definitions. +fn test() {} diff --git a/serde_with_macros/tests/compiler-messages.rs b/serde_with_macros/tests/compiler-messages.rs new file mode 100644 index 00000000..bda53441 --- /dev/null +++ b/serde_with_macros/tests/compiler-messages.rs @@ -0,0 +1,19 @@ +extern crate compiletest_rs as compiletest; + +use std::path::PathBuf; + +fn run_mode(mode: &'static str) { + let mut config = compiletest::Config::default(); + + config.mode = mode.parse().expect("Invalid mode"); + config.src_base = PathBuf::from(format!("tests/{}", mode)); + config.link_deps(); // Populate config.target_rustcflags with dependencies on the path + config.clean_rmeta(); // If your tests import the parent crate, this helps with E0464 + + compiletest::run_tests(&config); +} + +#[test] +fn compile_test() { + run_mode("compile-fail"); +} diff --git a/serde_with_macros/tests/skip_serializing_null.rs b/serde_with_macros/tests/skip_serializing_null.rs new file mode 100644 index 00000000..7fb43bc9 --- /dev/null +++ b/serde_with_macros/tests/skip_serializing_null.rs @@ -0,0 +1,154 @@ +extern crate pretty_assertions; +extern crate serde; +extern crate serde_json; +extern crate serde_with_macros; + +use pretty_assertions::assert_eq; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use serde_with_macros::skip_serializing_none; + +macro_rules! test { + ($fn:ident, $struct:ident) => { + #[test] + fn $fn() { + let expected = json!({}); + let data = $struct { + a: None, + b: None, + c: None, + d: None, + }; + let res = serde_json::to_value(&data).unwrap(); + assert_eq!(expected, res); + assert_eq!(data, serde_json::from_value(res).unwrap()); + } + }; +} + +macro_rules! test_tuple { + ($fn:ident, $struct:ident) => { + #[test] + fn $fn() { + let expected = json!([]); + let data = $struct(None, None); + let res = serde_json::to_value(&data).unwrap(); + assert_eq!(expected, res); + } + }; +} + +#[skip_serializing_none] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +struct DataBasic { + a: Option, + b: Option, + c: Option, + d: Option, +} +test!(test_basic, DataBasic); + +#[skip_serializing_none] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +struct DataFullyQualified { + a: ::std::option::Option, + b: std::option::Option, + c: ::std::option::Option, + d: core::option::Option, +} +test!(test_fully_qualified, DataFullyQualified); + +fn never(_t: &T) -> bool { + false +} + +#[skip_serializing_none] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +struct DataExistingAnnotation { + #[serde(skip_serializing_if = "Option::is_none")] + a: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "abc")] + b: Option, + #[serde(default)] + c: Option, + #[serde(skip_serializing_if = "never")] + #[serde(rename = "name")] + d: Option, +} + +#[test] +fn test_existing_annotation() { + let expected = json!({ "name": null }); + let data = DataExistingAnnotation { + a: None, + b: None, + c: None, + d: None, + }; + let res = serde_json::to_value(&data).unwrap(); + assert_eq!(expected, res); + assert_eq!(data, serde_json::from_value(res).unwrap()); +} + +#[skip_serializing_none] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +struct DataSerializeAlways { + #[serialize_always] + a: Option, + #[serialize_always] + b: Option, + c: i64, + #[serialize_always] + d: Option, +} + +#[test] +fn test_serialize_always() { + let expected = json!({ + "a": null, + "b": null, + "c": 0, + "d": null + }); + let data = DataSerializeAlways { + a: None, + b: None, + c: 0, + d: None, + }; + let res = serde_json::to_value(&data).unwrap(); + assert_eq!(expected, res); + assert_eq!(data, serde_json::from_value(res).unwrap()); +} + +#[skip_serializing_none] +#[derive(Debug, Eq, PartialEq, Serialize)] +struct DataTuple(Option, std::option::Option); +test_tuple!(test_tuple, DataTuple); + +#[skip_serializing_none] +#[derive(Debug, Eq, PartialEq, Serialize)] +enum DataEnum { + Tuple(Option, std::option::Option), + Struct { + a: Option, + b: Option, + }, +} + +#[test] +fn test_enum() { + let expected = json!({ + "Tuple": [] + }); + let data = DataEnum::Tuple(None, None); + let res = serde_json::to_value(&data).unwrap(); + assert_eq!(expected, res); + + let expected = json!({ + "Struct": {} + }); + let data = DataEnum::Struct { a: None, b: None }; + let res = serde_json::to_value(&data).unwrap(); + assert_eq!(expected, res); +} diff --git a/serde_with_macros/tests/version_numbers.rs b/serde_with_macros/tests/version_numbers.rs new file mode 100644 index 00000000..9cbd3b3a --- /dev/null +++ b/serde_with_macros/tests/version_numbers.rs @@ -0,0 +1,6 @@ +extern crate version_sync; + +#[test] +fn test_html_root_url() { + version_sync::assert_html_root_url_updated!("src/lib.rs"); +} diff --git a/src/lib.rs b/src/lib.rs index c0bfefee..0101fcad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,6 +83,8 @@ extern crate chrono as chrono_crate; pub extern crate serde; #[cfg(feature = "json")] extern crate serde_json; +#[cfg(feature = "macros")] +extern crate serde_with_macros; #[cfg(feature = "chrono")] pub mod chrono; @@ -94,6 +96,11 @@ pub mod rust; #[doc(hidden)] pub mod with_prefix; +// Re-Export all proc_macros, as these should be seen as part of the serde_with crate +#[cfg(feature = "macros")] +#[doc(inline)] +pub use serde_with_macros::*; + /// Separator for string-based collection de/serialization pub trait Separator { /// Return the string delimiting two elements in the string-based collection