Skip to content

Commit

Permalink
Merge #46
Browse files Browse the repository at this point in the history
46: Add a `skip_serializing_null` attribute r=jonasbb a=jonasbb

The `skip_serializing_null` attribute can be added to any struct and adds `#[serde(skip_serializing_if = "Option::is_none")]` to every `Option` field.
The intended use is for parsing data, e.g., from APIs, which has many optional values.

It turns

```rust
#[skip_serializing_null]
#[derive(Serialize)]
struct Data {
    a: Option<String>,
    b: Option<String>,
    c: Option<String>,
    d: Option<String>,
}
```

into

```rust
#[derive(Serialize)]
struct Data {
    #[serde(skip_serializing_if = "Option::is_none")]
    a: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    b: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    c: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    d: Option<String>,
}
```

The issue was originally suggested at dtolnay/request-for-implementation#18.

# Missing

* [x] Is `skip_serializing_null` the best name? That is how serde or JSON name the empty value, in Rust it is none.
* [x] Support tuple structs
* [x] Support enums
* [x] Handle existing `skip_serializing_if` annotations, by skipping those fields
* [x] Support an additional attribute, which ensures the field is always serialized
* [x] Write compile tests, which ensure the correct error message
* [x] Write documentation for the feature

Co-authored-by: Jonas Bushart <[email protected]>
  • Loading branch information
bors[bot] and jonasbb committed Mar 31, 2019
2 parents 26d84b1 + c46179c commit 386022c
Show file tree
Hide file tree
Showing 14 changed files with 565 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
[workspace]
members = [
"serde_with_macros",
]

[package]
name = "serde_with"
version = "1.2.0"
Expand Down Expand Up @@ -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"
Expand Down
44 changes: 44 additions & 0 deletions serde_with_macros/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions serde_with_macros/LICENSE-APACHE
1 change: 1 addition & 0 deletions serde_with_macros/LICENSE-MIT
1 change: 1 addition & 0 deletions serde_with_macros/README.md
278 changes: 278 additions & 0 deletions serde_with_macros/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// #[serde(skip_serializing_if = "Option::is_none")]
/// b: Option<u64>,
/// #[serde(skip_serializing_if = "Option::is_none")]
/// c: Option<String>,
/// #[serde(skip_serializing_if = "Option::is_none")]
/// d: Option<bool>,
/// }
/// ```
///
/// 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<String>,
/// b: Option<u64>,
/// c: Option<String>,
/// d: Option<bool>,
/// }
/// ```
///
/// 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<T> = Option<T>;
///
/// #[skip_serializing_none]
/// #[derive(Serialize)]
/// struct Data {
/// a: MyOption<String>, // 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<String>,
/// }
/// ```
///
#[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<proc_macro2::TokenStream, String> {
// 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::<ItemStruct>(input.clone()) {
skip_serializing_none_handle_fields(&mut input.fields)?;
Ok(quote!(#input))
} else if let Ok(mut input) = syn::parse::<ItemEnum>(input.clone()) {
input
.variants
.iter_mut()
.map(|variant| skip_serializing_none_handle_fields(&mut variant.fields))
.collect::<Result<(), _>>()?;
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<Item = &'a mut Field>,
) -> 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())
}
}
}
Loading

0 comments on commit 386022c

Please sign in to comment.