dog_core/
schema.rs

1//! # Schema hooks (DogRS-native)
2//!
3//! Feathers-ish schema utilities:
4//! - ResolveData: mutate ctx.data for write methods
5//! - ValidateData: validate ctx.data for write methods
6//!
7//! Key detail: resolvers/validators take `&HookMeta<R,P>` (immutable view)
8//! to avoid borrow conflicts with `&mut ctx.data`.
9
10use std::sync::Arc;
11
12use anyhow::Result;
13use async_trait::async_trait;
14
15use crate::{DogBeforeHook, HookContext, ServiceHooks, ServiceMethodKind};
16
17/// Which write methods should a schema hook apply to?
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum WriteMethods {
20    Create,
21    Patch,
22    Update,
23    AllWrites,
24}
25
26impl WriteMethods {
27    #[inline]
28    pub fn matches(&self, method: &ServiceMethodKind) -> bool {
29        match self {
30            WriteMethods::AllWrites => matches!(
31                method,
32                ServiceMethodKind::Create | ServiceMethodKind::Patch | ServiceMethodKind::Update
33            ),
34            WriteMethods::Create => matches!(method, ServiceMethodKind::Create),
35            WriteMethods::Patch => matches!(method, ServiceMethodKind::Patch),
36            WriteMethods::Update => matches!(method, ServiceMethodKind::Update),
37        }
38    }
39}
40
41/// Immutable view of the hook context (safe to pass while mutating ctx.data).
42#[derive(Clone)]
43pub struct HookMeta<R, P>
44where
45    R: Send + 'static,
46    P: Send + Clone + 'static,
47{
48    pub tenant: crate::TenantContext,
49    pub method: crate::ServiceMethodKind,
50    pub params: P,
51    pub services: crate::ServiceCaller<R, P>,
52    pub config: crate::DogConfigSnapshot,
53}
54
55impl<R, P> HookMeta<R, P>
56where
57    R: Send + 'static,
58    P: Send + Clone + 'static,
59{
60    pub fn from_ctx(ctx: &crate::HookContext<R, P>) -> Self {
61        Self {
62            tenant: ctx.tenant.clone(),
63            method: ctx.method.clone(),
64            params: ctx.params.clone(),
65            services: ctx.services.clone(),
66            config: ctx.config.clone(),
67        }
68    }
69}
70
71pub type ValidateFn<R, P> =
72    Arc<dyn Fn(&R, &HookMeta<R, P>) -> Result<()> + Send + Sync + 'static>;
73
74pub type ResolveFn<R, P> =
75    Arc<dyn Fn(&mut R, &HookMeta<R, P>) -> Result<()> + Send + Sync + 'static>;
76
77/// Validate `ctx.data` for create/patch/update. (Feathers `validateData`)
78pub struct ValidateData<R, P>
79where
80    R: Send + 'static,
81    P: Send + Clone + 'static,
82{
83    methods: WriteMethods,
84    validator: ValidateFn<R, P>,
85}
86
87impl<R, P> ValidateData<R, P>
88where
89    R: Send + 'static,
90    P: Send + Clone + 'static,
91{
92    pub fn new(
93        validator: impl Fn(&R, &HookMeta<R, P>) -> Result<()> + Send + Sync + 'static,
94    ) -> Self {
95        Self {
96            methods: WriteMethods::AllWrites,
97            validator: Arc::new(validator),
98        }
99    }
100
101    pub fn with_methods(mut self, methods: WriteMethods) -> Self {
102        self.methods = methods;
103        self
104    }
105}
106
107#[async_trait]
108impl<R, P> DogBeforeHook<R, P> for ValidateData<R, P>
109where
110    R: Send + 'static,
111    P: Send + Clone + 'static,
112{
113    async fn run(&self, ctx: &mut HookContext<R, P>) -> Result<()> {
114        if !self.methods.matches(&ctx.method) {
115            return Ok(());
116        }
117
118        let meta = HookMeta::from_ctx(ctx);
119
120        let data = ctx
121            .data
122            .as_ref()
123            .ok_or_else(|| anyhow::anyhow!("ValidateData requires ctx.data on write methods"))?;
124
125        (self.validator)(data, &meta)
126    }
127}
128
129/// Resolve/mutate `ctx.data` for create/patch/update. (Feathers `resolveData`)
130pub struct ResolveData<R, P>
131where
132    R: Send + 'static,
133    P: Send + Clone + 'static,
134{
135    methods: WriteMethods,
136    resolver: ResolveFn<R, P>,
137}
138
139impl<R, P> ResolveData<R, P>
140where
141    R: Send + 'static,
142    P: Send + Clone + 'static,
143{
144    pub fn new(
145        resolver: impl Fn(&mut R, &HookMeta<R, P>) -> Result<()> + Send + Sync + 'static,
146    ) -> Self {
147        Self {
148            methods: WriteMethods::AllWrites,
149            resolver: Arc::new(resolver),
150        }
151    }
152
153    pub fn with_methods(mut self, methods: WriteMethods) -> Self {
154        self.methods = methods;
155        self
156    }
157}
158
159#[async_trait]
160impl<R, P> DogBeforeHook<R, P> for ResolveData<R, P>
161where
162    R: Send + 'static,
163    P: Send + Clone + 'static,
164{
165    async fn run(&self, ctx: &mut HookContext<R, P>) -> Result<()> {
166        if !self.methods.matches(&ctx.method) {
167            return Ok(());
168        }
169
170        // capture immutable meta first (no mutable borrow yet)
171        let meta = HookMeta::from_ctx(ctx);
172
173        // then mutably borrow data (no ctx immutable borrow needed now)
174        let data = ctx
175            .data
176            .as_mut()
177            .ok_or_else(|| anyhow::anyhow!("ResolveData requires ctx.data on write methods"))?;
178
179        (self.resolver)(data, &meta)
180    }
181}
182
183/// Tiny “rules” helper for nicer validation errors.
184#[derive(Default)]
185pub struct Rules {
186    errors: Vec<anyhow::Error>,
187}
188
189impl Rules {
190    pub fn new() -> Self {
191        Self { errors: Vec::new() }
192    }
193
194    pub fn non_empty(mut self, field: &str, v: &str) -> Self {
195        if v.trim().is_empty() {
196            self.errors
197                .push(anyhow::anyhow!("'{field}' must not be empty"));
198        }
199        self
200    }
201
202    pub fn min_len(mut self, field: &str, v: &str, n: usize) -> Self {
203        if v.chars().count() < n {
204            self.errors
205                .push(anyhow::anyhow!("'{field}' must be at least {n} chars"));
206        }
207        self
208    }
209
210    pub fn check(self) -> Result<()> {
211        if self.errors.is_empty() {
212            Ok(())
213        } else if self.errors.len() == 1 {
214            Err(self.errors.into_iter().next().unwrap())
215        } else {
216            let msg = self
217                .errors
218                .iter()
219                .map(|e| format!("- {e}"))
220                .collect::<Vec<_>>()
221                .join("\n");
222            Err(anyhow::anyhow!("Schema validation failed:\n{msg}"))
223        }
224    }
225}
226
227/// Fluent builder used by `ServiceHooks::schema(...)`.
228pub struct SchemaBuilder<'a, R, P>
229where
230    R: Send + 'static,
231    P: Send + Clone + 'static,
232{
233    hooks: &'a mut ServiceHooks<R, P>,
234    current_methods: WriteMethods,
235}
236
237impl<'a, R, P> SchemaBuilder<'a, R, P>
238where
239    R: Send + 'static,
240    P: Send + Clone + 'static,
241{
242    fn new(hooks: &'a mut ServiceHooks<R, P>) -> Self {
243        Self {
244            hooks,
245            current_methods: WriteMethods::AllWrites,
246        }
247    }
248
249    pub fn on_create(&mut self) -> &mut Self {
250        self.current_methods = WriteMethods::Create;
251        self
252    }
253
254    pub fn on_patch(&mut self) -> &mut Self {
255        self.current_methods = WriteMethods::Patch;
256        self
257    }
258
259    pub fn on_update(&mut self) -> &mut Self {
260        self.current_methods = WriteMethods::Update;
261        self
262    }
263
264    pub fn on_writes(&mut self) -> &mut Self {
265        self.current_methods = WriteMethods::AllWrites;
266        self
267    }
268
269    pub fn resolve(
270        &mut self,
271        f: impl Fn(&mut R, &HookMeta<R, P>) -> Result<()> + Send + Sync + 'static,
272    ) -> &mut Self {
273        let hook = ResolveData::<R, P>::new(f).with_methods(self.current_methods);
274        self.hooks.before_all(Arc::new(hook));
275        self
276    }
277
278    pub fn validate(
279        &mut self,
280        f: impl Fn(&R, &HookMeta<R, P>) -> Result<()> + Send + Sync + 'static,
281    ) -> &mut Self {
282        let hook = ValidateData::<R, P>::new(f).with_methods(self.current_methods);
283        self.hooks.before_all(Arc::new(hook));
284        self
285    }
286}
287
288/// Extension method: `hooks.schema(|s| ...)`
289pub trait SchemaHooksExt<R, P>
290where
291    R: Send + 'static,
292    P: Send + Clone + 'static,
293{
294    fn schema<F>(&mut self, f: F) -> &mut Self
295    where
296        F: FnOnce(&mut SchemaBuilder<'_, R, P>);
297}
298
299impl<R, P> SchemaHooksExt<R, P> for ServiceHooks<R, P>
300where
301    R: Send + 'static,
302    P: Send + Clone + 'static,
303{
304    fn schema<F>(&mut self, f: F) -> &mut Self
305    where
306        F: FnOnce(&mut SchemaBuilder<'_, R, P>),
307    {
308        let mut b = SchemaBuilder::new(self);
309        f(&mut b);
310        self
311    }
312}