Skip to main content

siderite_validation/
hooks.rs

1//! Model hooks: validators, computed fields and serializers.
2//!
3//! `#[model_hooks] impl User { ... }` implements [`ModelHooks`] from
4//! `#[field_validator]`, `#[model_validator]`, `#[computed_field]`,
5//! `#[field_serializer]` and `#[model_serializer]` methods. The
6//! `Validate`/`Schema` derives call the hooks **without** an opt-in flag via
7//! [`Probe`] (autoref specialisation): if the model implements
8//! `ModelHooks` the hooks run, otherwise the calls compile to no-ops.
9//!
10//! Probing resolves at the call site in generated code, so it only works when
11//! the model type is concrete. Generic models (`Page<T>`) that have hooks must
12//! say so with `#[model_config(hooks)]`, which makes the derive call
13//! `<Self as ModelHooks>` directly.
14//!
15//! # Execution order (deterministic)
16//! 1. `before_model` on the raw object (model `mode = "before"`).
17//! 2. For each field in declaration order: `before_field`, then the field
18//!    type's own `prepare` (coercion, constraints on the raw value).
19//! 3. Deserialization.
20//! 4. Field constraints, then `after_fields` (field `mode = "after"`
21//!    validators, declaration order), then `after_model`.
22
23use crate::context::ValidationContext;
24use crate::dump::{DumpError, DumpOptions};
25use crate::schema::SchemaRegistry;
26use serde_json::{Map, Value};
27use std::marker::PhantomData;
28
29/// Hooks a model can provide. Every method defaults to doing nothing.
30pub trait ModelHooks {
31    /// Model before-validators, run on the raw input object.
32    fn before_model(_input: &mut Value, _ctx: &mut ValidationContext) {}
33
34    /// Field before-validators for the field whose Rust name is `field`,
35    /// run on its raw input slot (location already set to the field).
36    fn before_field(_field: &str, _input: &mut Value, _ctx: &mut ValidationContext) {}
37
38    /// Field after-validators (each reports at its field's location).
39    fn after_fields(&self, _ctx: &mut ValidationContext) {}
40
41    /// Model after-validators.
42    fn after_model(&self, _ctx: &mut ValidationContext) {}
43
44    /// Insert computed fields into the serialized object.
45    ///
46    /// # Errors
47    /// Serialization failures of computed values.
48    fn computed_fields(
49        &self,
50        _out: &mut Map<String, Value>,
51        _opts: &DumpOptions,
52    ) -> Result<(), DumpError> {
53        Ok(())
54    }
55
56    /// Document computed fields (added as `readOnly` properties).
57    fn computed_schema(_properties: &mut Map<String, Value>, _registry: &mut SchemaRegistry) {}
58
59    /// Field serializer for the field whose Rust name is `field`.
60    ///
61    /// # Errors
62    /// Serialization failures.
63    fn serialize_field(&self, _field: &str, value: Value) -> Result<Value, DumpError> {
64        Ok(value)
65    }
66
67    /// Model serializer applied to the finished object.
68    ///
69    /// # Errors
70    /// Serialization failures.
71    fn serialize_model(&self, value: Value) -> Result<Value, DumpError> {
72        Ok(value)
73    }
74}
75
76/// Zero-sized probe used by generated code: call hooks on `&&Probe::<T>::new()`.
77#[doc(hidden)]
78pub struct Probe<T: ?Sized>(PhantomData<fn() -> PhantomData<T>>);
79
80impl<T: ?Sized> Probe<T> {
81    /// New probe.
82    pub const fn new() -> Self {
83        Self(PhantomData)
84    }
85}
86
87impl<T: ?Sized> Default for Probe<T> {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93macro_rules! probe_traits {
94    ($($(#[$doc:meta])* fn $name:ident($($arg:ident: $ty:ty),*) $(-> $ret:ty)? => |$($d:ident),*| $default:expr;)*) => {
95        /// Selected when the model implements [`ModelHooks`].
96        #[doc(hidden)]
97        pub trait ViaHooks<T: ?Sized> {
98            $($(#[$doc])* fn $name(&self, $($arg: $ty),*) $(-> $ret)?;)*
99        }
100
101        /// Fallback when the model has no hooks.
102        #[doc(hidden)]
103        pub trait ViaDefault<T: ?Sized> {
104            $($(#[$doc])* fn $name(&self, $($arg: $ty),*) $(-> $ret)?;)*
105        }
106
107        impl<T: ?Sized> ViaDefault<T> for Probe<T> {
108            $(
109                #[allow(unused_variables)]
110                fn $name(&self, $($arg: $ty),*) $(-> $ret)? {
111                    let ($($d,)*) = ($($d,)*);
112                    $default
113                }
114            )*
115        }
116    };
117}
118
119probe_traits! {
120    /// Whether the model implements hooks, requiring value-based dumping.
121    fn has_hooks(model: &T) -> bool => |model| false;
122    /// Probe for [`ModelHooks::before_model`].
123    fn before_model(input: &mut Value, ctx: &mut ValidationContext) => |input, ctx| ();
124    /// Probe for [`ModelHooks::before_field`].
125    fn before_field(field: &str, input: &mut Value, ctx: &mut ValidationContext) => |field, input, ctx| ();
126    /// Probe for [`ModelHooks::after_fields`].
127    fn after_fields(model: &T, ctx: &mut ValidationContext) => |model, ctx| ();
128    /// Probe for [`ModelHooks::after_model`].
129    fn after_model(model: &T, ctx: &mut ValidationContext) => |model, ctx| ();
130    /// Probe for [`ModelHooks::computed_fields`].
131    fn computed_fields(model: &T, out: &mut Map<String, Value>, opts: &DumpOptions) -> Result<(), DumpError> => |model, out, opts| Ok(());
132    /// Probe for [`ModelHooks::computed_schema`].
133    fn computed_schema(properties: &mut Map<String, Value>, registry: &mut SchemaRegistry) => |properties, registry| ();
134    /// Probe for [`ModelHooks::serialize_field`].
135    fn serialize_field(model: &T, field: &str, value: Value) -> Result<Value, DumpError> => |model, field, value| Ok(value);
136    /// Probe for [`ModelHooks::serialize_model`].
137    fn serialize_model(model: &T, value: Value) -> Result<Value, DumpError> => |model, value| Ok(value);
138}
139
140impl<T: ModelHooks> ViaHooks<T> for &Probe<T> {
141    fn has_hooks(&self, _: &T) -> bool {
142        true
143    }
144
145    fn before_model(&self, input: &mut Value, ctx: &mut ValidationContext) {
146        T::before_model(input, ctx);
147    }
148    fn before_field(&self, field: &str, input: &mut Value, ctx: &mut ValidationContext) {
149        T::before_field(field, input, ctx);
150    }
151    fn after_fields(&self, model: &T, ctx: &mut ValidationContext) {
152        model.after_fields(ctx);
153    }
154    fn after_model(&self, model: &T, ctx: &mut ValidationContext) {
155        model.after_model(ctx);
156    }
157    fn computed_fields(
158        &self,
159        model: &T,
160        out: &mut Map<String, Value>,
161        opts: &DumpOptions,
162    ) -> Result<(), DumpError> {
163        model.computed_fields(out, opts)
164    }
165    fn computed_schema(&self, properties: &mut Map<String, Value>, registry: &mut SchemaRegistry) {
166        T::computed_schema(properties, registry);
167    }
168    fn serialize_field(&self, model: &T, field: &str, value: Value) -> Result<Value, DumpError> {
169        model.serialize_field(field, value)
170    }
171    fn serialize_model(&self, model: &T, value: Value) -> Result<Value, DumpError> {
172        model.serialize_model(value)
173    }
174}
175
176#[cfg(test)]
177// Autoref specialisation needs the explicit `&&`; generated code allows this too.
178#[allow(clippy::needless_borrow)]
179mod tests {
180    use super::*;
181    use serde_json::json;
182
183    struct WithHooks;
184    impl ModelHooks for WithHooks {
185        fn after_model(&self, ctx: &mut ValidationContext) {
186            ctx.error("custom", "from hook");
187        }
188        fn serialize_model(&self, _value: Value) -> Result<Value, DumpError> {
189            Ok(json!("hooked"))
190        }
191    }
192
193    struct WithoutHooks;
194
195    #[test]
196    fn probe_selects_hooks_when_implemented() {
197        let mut ctx = ValidationContext::new();
198        (&&Probe::<WithHooks>::new()).after_model(&WithHooks, &mut ctx);
199        assert_eq!(ctx.error_count(), 1);
200        let out = (&&Probe::<WithHooks>::new())
201            .serialize_model(&WithHooks, json!(1))
202            .unwrap();
203        assert_eq!(out, json!("hooked"));
204    }
205
206    #[test]
207    fn probe_falls_back_to_no_op() {
208        let mut ctx = ValidationContext::new();
209        (&&Probe::<WithoutHooks>::new()).after_model(&WithoutHooks, &mut ctx);
210        assert_eq!(ctx.error_count(), 0);
211        let out = (&&Probe::<WithoutHooks>::new())
212            .serialize_model(&WithoutHooks, json!(1))
213            .unwrap();
214        assert_eq!(out, json!(1));
215    }
216}