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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,19 @@ All notable changes to this project will be documented in this file.
### New features

* Add `#[serde_shape(serialize_with = "path", deserialize_with = "path")]` hooks for custom Serde functions and foreign representations.
* Add directional `#[serde_shape(bound(...))]` overrides for generic custom shape hooks.
* Add `SerializeShapeGraph::root_definition` and `DeserializeShapeGraph::root_definition` for directly inspecting named root types.
* Preserve Rust doc comments on derived containers, variants, and fields as user-facing descriptions.

### Bug fixes

* Reflect the proxy type used by Serde `from`, `try_from`, and `into` container attributes.
* Follow Serde's directional bounds for `Cow`: serialization reflects the borrowed type and deserialization reflects the owned type.
* Make IP and socket address shapes available in `no_std` builds through `core::net`.
* Preserve qualified Serde default paths without token-rendering spaces.

### Improvements

* Allow unsized types such as `str` and slices to use the `SerializeShape::serialize_shape` and `DeserializeShape::deserialize_shape` convenience methods directly.
* Document runtime graph construction, format-dependent unions, graph-local identifiers, built-in coverage, and custom representation boundaries.
* Replace broad debug snapshots with focused behavior assertions and remove the snapshot-testing dependency.
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,14 @@ Typical use cases:

Field shapes expose `wire_shape` as the source of truth for regular values, flattened fields, inline transparent fields, and omitted fields. Custom serializer/deserializer boundaries are represented by `ShapeRef::Opaque`, including when they are flattened or inline.

You may use [`schemars`](https://docs.rs/schemars) for JSON Schema generation and validation. But `schemars` is not a general-purpose Serde shape reflection library, and it does not support all Serde attributes. `serde-shape` is designed to be a more complete and general-purpose reflection of Serde shapes.
If the consumer needs JSON Schema, [`schemars`](https://docs.rs/schemars) directly targets that format. `serde-shape` instead keeps serialization and deserialization shapes separate and leaves format-specific export and validation to downstream tools.

## Example

The following example shows how to inspect a nested config type.

```rust
use serde_shape::{
DeserializeDefinitionKind, DeserializeShape, FieldsStyle, ShapeRef,
};
use serde_shape::{DeserializeDefinitionKind, DeserializeShape, FieldsStyle};

#[derive(DeserializeShape)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
Expand All @@ -81,10 +79,7 @@ struct TlsConfig {
}

let graph = Config::deserialize_shape();
let ShapeRef::Definition(config_id) = graph.root() else {
panic!("Config should produce a named definition");
};
let definition = graph.definition(*config_id).unwrap();
let definition = graph.root_definition().unwrap();

let DeserializeDefinitionKind::Struct(shape) = &definition.kind else {
panic!("Config should produce a struct shape");
Expand Down Expand Up @@ -136,10 +131,14 @@ struct Config {

Each function receives the current graph context and returns a `ShapeRef`. It may delegate to another type's shape implementation or construct a custom shape directly. A custom shape function is an assertion about the Serde behavior; `serde-shape` cannot verify that the declared shape matches the serializer or deserializer implementation.

For a generic custom hook, container-level `#[serde_shape(bound(serialize = "...", deserialize = "..."))]` replaces the automatically inferred bounds in the corresponding direction, following Serde's bound-override convention.

## Model boundaries

Shape graphs are an inspection API, not a stable interchange format. `ShapeId` values are local to one graph, and definition ordering and `Debug` output are not persistence contracts.

Definitions may be recursive. A `ShapeRef::Definition` is a graph edge, so walkers must detect repeated `ShapeId` values instead of expanding definitions indefinitely.

Types that branch on `Serializer::is_human_readable()` or `Deserializer::is_human_readable()` may expose a union of their known representations. The graph describes the possible Serde calls across formats; it is not specialized for one serializer format.

## Feature flags
Expand All @@ -159,11 +158,12 @@ The built-in implementations follow Serde's own data-model calls in each directi
| Containers | `Option`, `Result`, arrays, slices for serialization, tuples through arity 16, `Vec`, `VecDeque`, `LinkedList`, `BinaryHeap`, `BTreeSet`, and `BTreeMap` |
| Wrappers | References, `Box`, `Cow`, `Cell`, `RefCell`, `Wrapping`, `Reverse`, and `PhantomData` |
| Time | `core::time::Duration` |
| `std` feature | `HashMap`, `HashSet`, `Path`, `PathBuf`, IP and socket address types, `Mutex`, and `RwLock` |
| Network | `core::net` IP and socket address types |
| `std` feature | `HashMap`, `HashSet`, `Path`, `PathBuf`, `Mutex`, and `RwLock` |

Network address shapes are unions of their human-readable string representation and their compact Serde representation. A serialized byte slice is a sequence, while borrowed byte deserialization uses `ShapeRef::Bytes`.

For an unsupported foreign type, use a local newtype and implement `SerializeShape` or `DeserializeShape` manually. Custom Serde functions remain visible as opaque boundaries because their wire behavior cannot be inferred.
For an unsupported foreign type, use a local newtype and implement `SerializeShape` or `DeserializeShape` manually. Custom Serde functions remain opaque by default because their wire behavior cannot be inferred; use a `serde_shape` custom hook when the representation is known.

## `no_std` support

Expand All @@ -183,4 +183,4 @@ See the [contributor guide](CONTRIBUTING.md) for the development workflow and te

## License

This project is licensed under the [Apache License, Version 2.0](https://github.com/fast/serde-shape/blob/main/LICENSE).
This project is licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).
33 changes: 29 additions & 4 deletions serde-shape-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,28 +153,46 @@ fn validate_shape_attrs(container: &ast::Container<'_>) -> syn::Result<()> {
if !attrs.is_empty() {
return Err(syn::Error::new_spanned(
variant.original,
"serde_shape custom functions are supported on containers and fields, not variants",
"serde_shape attributes are not supported on variants",
));
}
for field in &variant.fields {
ShapeAttrs::parse(&field.original.attrs)?;
validate_field_shape_attrs(field)?;
}
}
}
ast::Data::Struct(_, fields) => {
for field in fields {
ShapeAttrs::parse(&field.original.attrs)?;
validate_field_shape_attrs(field)?;
}
}
}
Ok(())
}

fn validate_field_shape_attrs(field: &ast::Field<'_>) -> syn::Result<()> {
let attrs = ShapeAttrs::parse(&field.original.attrs)?;
if attrs.has_bound() {
return Err(syn::Error::new_spanned(
field.original,
"serde_shape bounds are supported on containers, not fields",
));
}
Ok(())
}

fn add_serialize_shape_bounds(
generics: &mut syn::Generics,
container: &ast::Container<'_>,
shape_attrs: &ShapeAttrs,
) -> syn::Result<()> {
if let Some(predicates) = shape_attrs.serialize_bound() {
generics
.make_where_clause()
.predicates
.extend(predicates.iter().cloned());
return Ok(());
}
let type_params: BTreeSet<_> = generics
.type_params()
.map(|param| param.ident.to_string())
Expand Down Expand Up @@ -229,6 +247,13 @@ fn add_deserialize_shape_bounds(
container: &ast::Container<'_>,
shape_attrs: &ShapeAttrs,
) -> syn::Result<()> {
if let Some(predicates) = shape_attrs.deserialize_bound() {
generics
.make_where_clause()
.predicates
.extend(predicates.iter().cloned());
return Ok(());
}
let type_params: BTreeSet<_> = generics
.type_params()
.map(|param| param.ident.to_string())
Expand Down Expand Up @@ -911,7 +936,7 @@ fn default_shape(default: &attr::Default) -> TokenStream2 {
attr::Default::None => quote!(__serde_shape::DefaultShape::None),
attr::Default::Default => quote!(__serde_shape::DefaultShape::Default),
attr::Default::Path(path) => {
let path = lit(path.to_token_stream().to_string());
let path = lit(path.to_token_stream().to_string().replace(' ', ""));
quote!(__serde_shape::DefaultShape::Path(#path))
}
}
Expand Down
52 changes: 50 additions & 2 deletions serde-shape-derive/src/shape_attr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ use syn::Expr;
use syn::ExprPath;
use syn::Lit;
use syn::LitStr;
use syn::Token;
use syn::WherePredicate;
use syn::meta::ParseNestedMeta;
use syn::punctuated::Punctuated;
use syn::spanned::Spanned;

#[derive(Default)]
pub struct ShapeAttrs {
serialize_with: Option<(ExprPath, Span)>,
deserialize_with: Option<(ExprPath, Span)>,
serialize_bound: Option<(Vec<WherePredicate>, Span)>,
deserialize_bound: Option<(Vec<WherePredicate>, Span)>,
}

impl ShapeAttrs {
Expand All @@ -49,9 +54,29 @@ impl ShapeAttrs {
parse_path(&meta)?,
meta.path.span(),
)
} else if meta.path.is_ident("bound") {
meta.parse_nested_meta(|meta| {
if meta.path.is_ident("serialize") {
set_once(
&mut parsed.serialize_bound,
parse_bound(&meta)?,
meta.path.span(),
)
} else if meta.path.is_ident("deserialize") {
set_once(
&mut parsed.deserialize_bound,
parse_bound(&meta)?,
meta.path.span(),
)
} else {
Err(meta.error(
"unknown serde_shape bound; expected `serialize` or `deserialize`",
))
}
})
} else {
Err(meta.error(
"unknown serde_shape attribute; expected `serialize_with` or `deserialize_with`",
"unknown serde_shape attribute; expected `serialize_with`, `deserialize_with`, or `bound`",
))
}
})?;
Expand All @@ -68,8 +93,24 @@ impl ShapeAttrs {
self.deserialize_with.as_ref().map(|(path, _)| path)
}

pub fn serialize_bound(&self) -> Option<&[WherePredicate]> {
self.serialize_bound
.as_ref()
.map(|(predicates, _)| predicates.as_slice())
}

pub fn deserialize_bound(&self) -> Option<&[WherePredicate]> {
self.deserialize_bound
.as_ref()
.map(|(predicates, _)| predicates.as_slice())
}

pub fn has_bound(&self) -> bool {
self.serialize_bound.is_some() || self.deserialize_bound.is_some()
}

pub fn is_empty(&self) -> bool {
self.serialize_with.is_none() && self.deserialize_with.is_none()
self.serialize_with.is_none() && self.deserialize_with.is_none() && !self.has_bound()
}
}

Expand Down Expand Up @@ -113,6 +154,13 @@ fn parse_path(meta: &ParseNestedMeta<'_>) -> syn::Result<ExprPath> {
value.parse()
}

fn parse_bound(meta: &ParseNestedMeta<'_>) -> syn::Result<Vec<WherePredicate>> {
let value = meta.value()?;
let value: LitStr = value.parse()?;
let predicates = value.parse_with(Punctuated::<WherePredicate, Token![,]>::parse_terminated)?;
Ok(predicates.into_iter().collect())
}

fn set_once<T>(slot: &mut Option<(T, Span)>, value: T, span: Span) -> syn::Result<()> {
if slot.is_some() {
return Err(syn::Error::new(span, "duplicate serde_shape attribute"));
Expand Down
1 change: 0 additions & 1 deletion serde-shape/src/impls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
// limitations under the License.

mod container;
#[cfg(feature = "std")]
mod net;
mod primitive;
mod result;
Expand Down
12 changes: 6 additions & 6 deletions serde-shape/src/impls/net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
use alloc::boxed::Box;
use alloc::vec;
use core::any::type_name;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use std::net::SocketAddr;
use std::net::SocketAddrV4;
use std::net::SocketAddrV6;
use core::net::IpAddr;
use core::net::Ipv4Addr;
use core::net::Ipv6Addr;
use core::net::SocketAddr;
use core::net::SocketAddrV4;
use core::net::SocketAddrV6;

use crate::DefaultShape;
use crate::DeserializeContainerAttributes;
Expand Down
Loading