Skip to main content

ddd_toolkit_macro/
lib.rs

1//! Derive macros for `ddd-toolkit-core`'s value-object-shaped traits:
2//! [`ValueObject`](derive_value_object), [`EntityId`](derive_entity_id),
3//! [`SecretVo`](derive_secret_vo), and [`EnumVo`](derive_enum_vo). Works
4//! alongside `ddd-toolkit-core` directly, or through the `ddd-toolkit`
5//! facade crate - the generated code resolves the right crate path either
6//! way, so it doesn't matter which one the caller's `Cargo.toml` names.
7//!
8//! Three of the four derives ([`ValueObject`](derive_value_object),
9//! [`EntityId`](derive_entity_id), [`SecretVo`](derive_secret_vo)) apply to
10//! a tuple struct with exactly one non-`pub` field, e.g. `struct
11//! Email(String);`. The fourth, [`EnumVo`](derive_enum_vo), applies to a
12//! fieldless enum instead. All four accept `#[vo(...)]` attributes to
13//! configure the generated code; each derive's own docs list which
14//! attributes it accepts and why the others are rejected.
15//!
16//! # Example
17//!
18//! ```
19//! use ddd_toolkit_core::domain::{ValidationError, Wrapped};
20//! use ddd_toolkit_macro::ValueObject;
21//!
22//! #[derive(ValueObject, Clone, PartialEq, Debug)]
23//! #[vo(validate = "validate_email")]
24//! struct Email(String);
25//!
26//! fn validate_email(s: &str) -> Result<(), ValidationError> {
27//!     if s.contains('@') {
28//!         Ok(())
29//!     } else {
30//!         Err(ValidationError::new("Email", "missing @"))
31//!     }
32//! }
33//!
34//! let email = Email::try_from("a@b.com".to_string())?;
35//! assert_eq!(email.as_inner(), "a@b.com");
36//! assert!(Email::try_from("no-at-sign".to_string()).is_err());
37//! # Ok::<(), ValidationError>(())
38//! ```
39#![deny(missing_docs)]
40
41mod attributes;
42mod entity_id;
43mod enum_vo;
44mod krate;
45mod secret_vo;
46mod vo;
47
48use crate::enum_vo::expand_enum_vo;
49use crate::vo::expand_value_object;
50use crate::{entity_id::expand_entity_id, secret_vo::expand_secret_vo};
51use proc_macro::TokenStream;
52use syn::{DeriveInput, parse_macro_input};
53
54/// Derives [`ValueObject`], [`Wrapped`], `TryFrom<Inner>`, `From<Self> for
55/// Inner`, and `Display` for a single-field tuple struct.
56///
57/// [`ValueObject`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.ValueObject.html
58/// [`Wrapped`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.Wrapped.html
59///
60/// # Requirements
61///
62/// The target must be a tuple struct with exactly one field, and that
63/// field must not be `pub`. A `pub` field would let callers construct the
64/// type directly (`Email("anything".into())`), bypassing the validation
65/// this derive puts into the generated `TryFrom` impl - so both violations
66/// are rejected at compile time rather than left as a foot-gun.
67///
68/// # Attributes
69///
70/// All are passed as `#[vo(...)]` on the struct itself:
71///
72/// - `validate = "path::to::fn"` - runs `fn(&Inner) -> Result<(),
73///   ValidationError>` inside the generated `TryFrom` before the value is
74///   constructed. Without it, `TryFrom::Error` is
75///   [`Infallible`](std::convert::Infallible).
76/// - `no_display` - skips the generated `Display` impl, so a hand-written
77///   one can be provided instead.
78///
79/// `rename_all` and `zeroize` are rejected here: `rename_all` only affects
80/// [`EnumVo`](derive_enum_vo)'s variant names, and `zeroize` only makes
81/// sense on [`SecretVo`](derive_secret_vo), which owns a value worth
82/// scrubbing from memory on drop.
83///
84/// # `serde`
85///
86/// With this crate's own `serde` Cargo feature enabled (and the calling
87/// crate depending on `serde` directly - proc-macro output resolves bare
88/// paths at the call site, not here), this also derives `Serialize`
89/// (delegating to the inner value) and `Deserialize` (deserializing the
90/// inner value, then re-running it through the same validating `TryFrom`
91/// this derive generates, so an invalid value is rejected during
92/// deserialization exactly as it would be through `TryFrom` directly).
93///
94/// # Examples
95///
96/// ```
97/// use ddd_toolkit_core::domain::{ValidationError, Wrapped};
98/// use ddd_toolkit_macro::ValueObject;
99///
100/// #[derive(ValueObject, Clone, PartialEq, Debug)]
101/// #[vo(validate = "validate_email")]
102/// struct Email(String);
103///
104/// fn validate_email(s: &str) -> Result<(), ValidationError> {
105///     if s.contains('@') {
106///         Ok(())
107///     } else {
108///         Err(ValidationError::new("Email", "missing @"))
109///     }
110/// }
111///
112/// let email = Email::try_from("a@b.com".to_string())?;
113/// assert_eq!(email.as_inner(), "a@b.com");
114/// assert_eq!(email.to_string(), "a@b.com");
115/// assert!(Email::try_from("no-at-sign".to_string()).is_err());
116/// # Ok::<(), ValidationError>(())
117/// ```
118#[proc_macro_derive(ValueObject, attributes(vo))]
119pub fn derive_value_object(input: TokenStream) -> TokenStream {
120    let input = parse_macro_input!(input as DeriveInput);
121    expand_value_object(&input)
122        .unwrap_or_else(syn::Error::into_compile_error)
123        .into()
124}
125
126/// Derives [`EntityId`] - and everything
127/// [`#[derive(ValueObject)]`](derive_value_object) derives - for a
128/// single-field tuple struct.
129///
130/// [`EntityId`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.EntityId.html
131///
132/// On top of the `ValueObject` bundle, this adds `Eq`, `Hash`, `Ord`, and
133/// `PartialOrd`, all delegating to the wrapped field - two ids compare and
134/// hash equal exactly when their inner values do. That makes the result
135/// usable as a `HashMap`/`HashSet` key or in a sorted collection, which is
136/// the common case for an identifier type.
137///
138/// See [`derive_value_object`] for the shared requirements (single-field
139/// tuple struct, non-`pub` field) and `#[vo(...)]` attributes (`validate`,
140/// `no_display`) - both apply here unchanged, and `rename_all`/`zeroize`
141/// are rejected for the same reasons. `serde` support (see
142/// [`derive_value_object`]'s docs) applies here unchanged too.
143///
144/// # Examples
145///
146/// ```
147/// use ddd_toolkit_core::domain::Wrapped;
148/// use ddd_toolkit_macro::EntityId;
149/// use std::collections::HashSet;
150///
151/// #[derive(EntityId, Clone, PartialEq, Debug)]
152/// struct UserId(u64);
153///
154/// let a = UserId::try_from(1)?;
155/// let b = UserId::try_from(2)?;
156/// assert!(a < b);
157///
158/// let ids: HashSet<UserId> = HashSet::from([UserId::try_from(1)?, UserId::try_from(1)?]);
159/// assert_eq!(ids.len(), 1, "equal inner values dedupe to one id");
160/// # Ok::<(), std::convert::Infallible>(())
161/// ```
162#[proc_macro_derive(EntityId, attributes(vo))]
163pub fn derive_entity_id(input: TokenStream) -> TokenStream {
164    let input = parse_macro_input!(input as DeriveInput);
165    expand_entity_id(&input)
166        .unwrap_or_else(syn::Error::into_compile_error)
167        .into()
168}
169
170/// Derives [`SecretVo`] for a single-field tuple struct, with a `Debug`
171/// impl that always redacts the wrapped value.
172///
173/// [`SecretVo`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.SecretVo.html
174///
175/// Unlike [`ValueObject`](derive_value_object), this derive generates its
176/// own `Debug` impl (printing `TypeName(***)`, never the wrapped value) -
177/// so the target must not derive or implement `Debug` itself, or the two
178/// impls collide (`E0119`). It never generates `Display` either, which is
179/// why `no_display` is rejected here: there is nothing for it to suppress.
180///
181/// See [`derive_value_object`] for the shared single-field-tuple-struct
182/// requirement and the `validate` attribute, which behaves identically
183/// here. `rename_all` is rejected for the same reason as on `ValueObject`.
184///
185/// # Attributes
186///
187/// - `validate = "path::to::fn"` - see [`derive_value_object`].
188/// - `zeroize` - generates a `Drop` impl that calls
189///   [`Zeroize::zeroize`](https://docs.rs/zeroize/latest/zeroize/trait.Zeroize.html#tymethod.zeroize)
190///   on the wrapped value when it goes out of scope. Requires *this*
191///   crate's own `zeroize` Cargo feature to be enabled, and the *calling*
192///   crate to depend on the `zeroize` crate directly (the generated `Drop`
193///   impl references it by a bare path, which only resolves at the call
194///   site). Without the feature enabled, `#[vo(zeroize)]` is rejected at
195///   compile time with a message naming both requirements, rather than
196///   failing later with a confusing unresolved-crate error.
197///
198/// Unlike [`ValueObject`](derive_value_object)/[`EntityId`](derive_entity_id)/
199/// [`EnumVo`](derive_enum_vo), this derive never generates `serde` support,
200/// even with this crate's `serde` Cargo feature enabled: a `Serialize` impl
201/// on a secret risks writing the raw value to a log or response body by
202/// accident, which is exactly what this type exists to prevent. Wrap a
203/// `SecretVo` in your own type with an explicit, intentional `Serialize`
204/// impl if you truly need to send it somewhere.
205///
206/// # Examples
207///
208/// ```
209/// use ddd_toolkit_core::domain::SecretVo;
210/// use ddd_toolkit_macro::SecretVo as DeriveSecretVo;
211///
212/// #[derive(Clone, PartialEq, DeriveSecretVo)]
213/// struct ApiKey(String);
214///
215/// let secret = ApiKey::try_new("sekret".to_string())?;
216/// assert_eq!(secret.expose_secret(), "sekret");
217///
218/// let debug = format!("{secret:?}");
219/// assert_eq!(debug, "ApiKey(***)");
220/// assert!(!debug.contains("sekret"));
221/// # Ok::<(), std::convert::Infallible>(())
222/// ```
223///
224/// With the `zeroize` feature enabled on this crate, and `zeroize` in the
225/// caller's own dependencies:
226///
227/// ```ignore
228/// use ddd_toolkit_core::domain::SecretVo;
229/// use ddd_toolkit_macro::SecretVo as DeriveSecretVo;
230///
231/// #[derive(Clone, PartialEq, DeriveSecretVo)]
232/// #[vo(zeroize)]
233/// struct ZeroizingSecret([u8; 4]);
234///
235/// let secret = ZeroizingSecret::try_new([1, 2, 3, 4])?;
236/// drop(secret); // wipes the inner bytes on the way out
237/// # Ok::<(), std::convert::Infallible>(())
238/// ```
239#[proc_macro_derive(SecretVo, attributes(vo))]
240pub fn derive_secret_vo(input: TokenStream) -> TokenStream {
241    let input = parse_macro_input!(input as DeriveInput);
242    expand_secret_vo(&input)
243        .unwrap_or_else(syn::Error::into_compile_error)
244        .into()
245}
246
247/// Derives [`EnumVo`], `Display`, and `FromStr` for a fieldless enum.
248///
249/// [`EnumVo`]: https://docs.rs/ddd-toolkit-core/latest/ddd_toolkit_core/domain/trait.EnumVo.html
250///
251/// `EnumVo::variants()`, `Display`, and `FromStr` are all generated from
252/// the same variant list, so they can't drift out of sync with each other
253/// or with the enum's actual variants. Every variant must be a unit
254/// variant (no fields) - a variant carrying data isn't a simple
255/// enumeration, so the derive rejects it at compile time instead of
256/// silently ignoring the payload.
257///
258/// `validate` is rejected here: `FromStr` already exhaustively checks
259/// variant membership, so there is no "invalid but parseable" value left
260/// for a validator to catch. `zeroize` is rejected for the same reason as
261/// on [`ValueObject`](derive_value_object) - a fieldless enum owns nothing
262/// worth scrubbing.
263///
264/// # Attributes
265///
266/// - `no_display` - see [`derive_value_object`].
267/// - `rename_all = "..."` - renames every variant for `Display`/`FromStr`
268///   according to one of: `PascalCase` (the default - unchanged), `snake_case`,
269///   `SCREAMING_SNAKE_CASE`, `kebab-case`, `lowercase`, `UPPERCASE`, or
270///   `camelCase`. An unrecognized rule name is rejected at compile time,
271///   listing the valid options.
272///
273/// With this crate's own `serde` Cargo feature enabled (and the calling
274/// crate depending on `serde` directly), this also derives `Serialize`
275/// and `Deserialize`, both using the same variant/string table
276/// `Display`/`FromStr` are generated from - so serializing writes the
277/// `Display` string and deserializing accepts exactly what `FromStr`
278/// accepts, `rename_all` included.
279///
280/// # Examples
281///
282/// ```
283/// use ddd_toolkit_core::domain::EnumVo;
284/// use ddd_toolkit_macro::EnumVo as DeriveEnumVo;
285/// use std::str::FromStr;
286///
287/// #[derive(Debug, Clone, Copy, PartialEq, DeriveEnumVo)]
288/// enum Status {
289///     Active,
290///     Inactive,
291/// }
292///
293/// assert_eq!(Status::variants(), &[Status::Active, Status::Inactive]);
294/// assert_eq!(Status::Active.to_string(), "Active");
295/// assert_eq!(Status::from_str("Active"), Ok(Status::Active));
296/// assert!(Status::from_str("bogus").is_err());
297/// ```
298///
299/// With `rename_all`, `Display` and `FromStr` agree on the renamed form:
300///
301/// ```
302/// use ddd_toolkit_macro::EnumVo as DeriveEnumVo;
303/// use std::str::FromStr;
304///
305/// #[derive(Debug, Clone, Copy, PartialEq, DeriveEnumVo)]
306/// #[vo(rename_all = "snake_case")]
307/// enum HttpMethod {
308///     Get,
309///     PostRequest,
310/// }
311///
312/// assert_eq!(HttpMethod::PostRequest.to_string(), "post_request");
313/// assert_eq!(HttpMethod::from_str("post_request"), Ok(HttpMethod::PostRequest));
314/// assert!(HttpMethod::from_str("PostRequest").is_err());
315/// ```
316#[proc_macro_derive(EnumVo, attributes(vo))]
317pub fn derive_enum_vo(input: TokenStream) -> TokenStream {
318    let input = parse_macro_input!(input as DeriveInput);
319    expand_enum_vo(&input)
320        .unwrap_or_else(syn::Error::into_compile_error)
321        .into()
322}