Skip to main content

dog_core/
hooks.rs

1//! # Hooks: Dependency Injection (DogRS style)
2//!
3//! DogRS is **DI-first**: hooks should be small, portable, testable,
4//! and not depend on hidden global state.
5//!
6//! In FeathersJS, hooks often reach for `context.app` to access
7//! config/services. In DogRS, the **default** approach is:
8//! **inject what you need at construction time**.
9//!
10//! However, DogRS also supports an **optional, Feathers-like** runtime access
11//! pattern via `ctx.config` and `ctx.services` for cases where DI is awkward.
12//!
13//! ---
14//!
15//! ## The two supported styles
16//!
17//! ### A) Preferred: Dependency Injection (most hooks should do this)
18//! ✅ Best for: validation, auth policy checks (if cheap), input shaping,
19//! audit stamping, pagination clamping, etc.
20//!
21//! ```rust
22//! use std::sync::Arc;
23//! use anyhow::Result;
24//! use async_trait::async_trait;
25//! use dog_core::{DogBeforeHook, HookContext};
26//!
27//! struct EnforceMaxPage {
28//!     max: usize,
29//! }
30//!
31//! #[async_trait]
32//! impl<R, P> DogBeforeHook<R, P> for EnforceMaxPage
33//! where
34//!     R: Send + 'static,
35//!     P: Send + Clone + 'static,
36//! {
37//!     async fn run(&self, _ctx: &mut HookContext<R, P>) -> Result<()> {
38//!         // clamp pagination, etc...
39//!         Ok(())
40//!     }
41//! }
42//!
43//! // Registration:
44//! // let max = app.config_snapshot().get_usize("paginate.max").unwrap_or(50);
45//! // app.hooks(|h| { h.before_all(Arc::new(EnforceMaxPage { max })); });
46//! ```
47//!
48//! ### B) Optional: Context services/config (Feathers-like escape hatch)
49//! ✅ Best for: logging, auditing, light enrichment, or policy checks that
50//! genuinely need a separate service and DI is too rigid.
51//!
52//! DogRS may populate the hook context with:
53//! - `ctx.config`: a snapshot of app config at call time
54//! - `ctx.services`: a runtime service caller (typed downcast)
55//!
56//! ```rust
57//! use std::sync::Arc;
58//! use anyhow::Result;
59//! use async_trait::async_trait;
60//! use dog_core::{DogBeforeHook, HookContext};
61//!
62//! // Example types
63//! #[derive(Clone)]
64//! struct User { id: String }
65//! #[derive(Clone)]
66//! struct UserParams;
67//!
68//! struct AttachUser;
69//!
70//! #[async_trait]
71//! impl<Message, Params> DogBeforeHook<Message, Params> for AttachUser
72//! where
73//!     Message: Send + 'static,
74//!     Params: Send + Clone + 'static,
75//! {
76//!     async fn run(&self, ctx: &mut HookContext<Message, Params>) -> Result<()> {
77//!         // Read config snapshot (if provided by the app pipeline):
78//!         let _max = ctx.config.get_usize("paginate.max").unwrap_or(50);
79//!
80//!         // Runtime lookup of another service (typed):
81//!         let users = ctx.services.service::<User, UserParams>("users")?;
82//!
83//!         // NOTE: calling other services from hooks is powerful but risky.
84//!         // users.get(...).await?;
85//!         Ok(())
86//!     }
87//! }
88//! ```
89//!
90//! ---
91//!
92//! ## Why `ctx.services` (and not `ctx.service("...")`)?
93//!
94//! We keep the surface explicit:
95//! - `ctx` remains a *pure* per-call context
96//! - service lookup is grouped under `ctx.services` so it’s obvious when you’re
97//!   reaching outside the hook into the service graph.
98//!
99//! This mirrors the Feathers mental model (`context.app.service(...)`) without
100//! putting the whole `app` onto the hook context.
101//!
102//! ---
103//!
104//! ## Important warnings (read this if you use `ctx.services`)
105//!
106//! Service-to-service calls **inside hooks** can be dangerous because they can:
107//! - create hidden coupling (harder to reason about the dependency graph)
108//! - accidentally trigger nested hook pipelines (surprising behavior)
109//! - form cycles (A hook calls B which triggers a hook that calls A…)
110//! - cause performance cliffs (N+1 calls in hooks)
111//!
112//! Prefer service-to-service calls inside the **service implementation**
113//! (domain logic) rather than inside hooks.
114//!
115//! Use `ctx.services` inside hooks only for:
116//! - logging/auditing
117//! - lightweight enrichment that cannot live in the service
118//! - authorization checks that must query a separate policy service
119//!
120//! If you do it:
121//! - keep it fast and side-effect safe
122//! - avoid calling the *same* service you’re currently executing
123//! - avoid cascading calls (hook calls service which calls service which…)
124//!
125//! ---
126//!
127//! ## Type safety and mismatches
128//!
129//! `ctx.services.service::<R2, P2>("name")` performs a typed downcast.
130//! If you request a different `<R2, P2>` than what was registered,
131//! it returns a clear **type mismatch** error.
132//!
133//! This is deliberate: DogRS remains strongly typed even when providing
134//! a Feathers-like runtime lookup experience.
135//!
136
137
138
139use std::collections::HashMap;
140use std::future::Future;
141use std::pin::Pin;
142use std::sync::Arc;
143
144use anyhow::Result;
145use async_trait::async_trait;
146
147use crate::{ServiceMethodKind, TenantContext};
148
149pub enum HookResult<R> {
150    One(R),
151    Many(Vec<R>),
152}
153
154/// A typed, Feathers-inspired hook context.
155///
156/// This context flows through:
157/// around → before → service → after → error
158/// A typed, Feathers-inspired hook context.
159///
160/// This context flows through:
161/// around → before → service → after → error
162pub struct HookContext<R, P>
163where
164    R: Send + 'static,
165    P: Send + Clone + 'static,
166{
167    pub tenant: TenantContext,
168    pub method: ServiceMethodKind,
169    pub params: P,
170
171    /// Input data (create / patch / update)
172    pub data: Option<R>,
173
174    /// Output result (after hooks)
175    pub result: Option<HookResult<R>>,
176
177    /// Error captured during execution
178    pub error: Option<anyhow::Error>,
179
180    /// Feathers-style access to other services (runtime lookup)
181    pub services: crate::ServiceCaller<R, P>,
182
183    /// Immutable snapshot of app config for this call
184    pub config: crate::DogConfigSnapshot,
185}
186
187impl<R, P> HookContext<R, P>
188where
189    R: Send + 'static,
190    P: Send + Clone + 'static,
191{
192    pub fn new(
193        tenant: TenantContext,
194        method: ServiceMethodKind,
195        params: P,
196        services: crate::ServiceCaller<R, P>,
197        config: crate::DogConfigSnapshot,
198    ) -> Self {
199        Self {
200            tenant,
201            method,
202            params,
203            data: None,
204            result: None,
205            error: None,
206            services,
207            config,
208        }
209    }
210}
211
212/// Helper used by the pipeline:
213/// returns `all + method` hooks in that order.
214pub(crate) fn collect_method_hooks<T>(
215    all: &[T],
216    by_method: &std::collections::HashMap<crate::ServiceMethodKind, Vec<T>>,
217    method: &crate::ServiceMethodKind,
218) -> Vec<T>
219where
220    T: Clone,
221{
222    let mut hooks = all.to_vec();
223    if let Some(method_hooks) = by_method.get(method) {
224        hooks.extend(method_hooks.clone());
225    }
226    hooks
227}
228
229pub type HookFut<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
230
231type NextCall<R, P> = dyn for<'a> FnOnce(&'a mut HookContext<R, P>) -> HookFut<'a> + Send;
232
233/// Around hooks wrap the entire pipeline (like Feathers `around.all`)
234pub struct Next<R, P>
235where
236    R: Send + 'static,
237    P: Send + Clone + 'static,
238{
239    pub(crate) call: Box<NextCall<R, P>>,
240}
241
242impl<R, P> Next<R, P>
243where
244    R: Send + 'static,
245    P: Send + Clone + 'static,
246{
247    pub async fn run(self, ctx: &mut HookContext<R, P>) -> Result<()> {
248        (self.call)(ctx).await
249    }
250}
251
252
253#[async_trait]
254pub trait DogBeforeHook<R, P>: Send + Sync
255where
256    R: Send + 'static,
257    P: Send + Clone + 'static,
258{
259    async fn run(&self, ctx: &mut HookContext<R, P>) -> Result<()>;
260}
261
262#[async_trait]
263pub trait DogAfterHook<R, P>: Send + Sync
264where
265    R: Send + 'static,
266    P: Send + Clone + 'static,
267{
268    async fn run(&self, ctx: &mut HookContext<R, P>) -> Result<()>;
269}
270
271#[async_trait]
272pub trait DogErrorHook<R, P>: Send + Sync
273where
274    R: Send + 'static,
275    P: Send + Clone + 'static,
276{
277    async fn run(&self, ctx: &mut HookContext<R, P>) -> Result<()>;
278}
279
280#[async_trait]
281pub trait DogAroundHook<R, P>: Send + Sync
282where
283    R: Send + 'static,
284    P: Send + Clone + 'static,
285{
286    async fn run(&self, ctx: &mut HookContext<R, P>, next: Next<R, P>) -> Result<()>;
287}
288
289
290/// Feathers-style hooks container:
291///
292/// {
293///   around: { all, create, find },
294///   before: { all, create },
295///   after:  { all, find },
296///   error:  { all, create }
297/// }
298pub struct ServiceHooks<R, P> {
299    pub around_all: Vec<Arc<dyn DogAroundHook<R, P>>>,
300    pub before_all: Vec<Arc<dyn DogBeforeHook<R, P>>>,
301    pub after_all: Vec<Arc<dyn DogAfterHook<R, P>>>,
302    pub error_all: Vec<Arc<dyn DogErrorHook<R, P>>>,
303
304    pub around_by_method: HashMap<ServiceMethodKind, Vec<Arc<dyn DogAroundHook<R, P>>>>,
305    pub before_by_method: HashMap<ServiceMethodKind, Vec<Arc<dyn DogBeforeHook<R, P>>>>,
306    pub after_by_method: HashMap<ServiceMethodKind, Vec<Arc<dyn DogAfterHook<R, P>>>>,
307    pub error_by_method: HashMap<ServiceMethodKind, Vec<Arc<dyn DogErrorHook<R, P>>>>,
308}
309
310impl<R, P> Default for ServiceHooks<R, P> {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316impl<R, P> ServiceHooks<R, P> {
317    pub fn new() -> Self {
318        Self {
319            around_all: Vec::new(),
320            before_all: Vec::new(),
321            after_all: Vec::new(),
322            error_all: Vec::new(),
323            around_by_method: HashMap::new(),
324            before_by_method: HashMap::new(),
325            after_by_method: HashMap::new(),
326            error_by_method: HashMap::new(),
327        }
328    }
329
330    pub fn is_empty(&self) -> bool {
331        self.around_all.is_empty()
332            && self.before_all.is_empty()
333            && self.after_all.is_empty()
334            && self.error_all.is_empty()
335            && self.around_by_method.is_empty()
336            && self.before_by_method.is_empty()
337            && self.after_by_method.is_empty()
338            && self.error_by_method.is_empty()
339    }
340
341    // ─────────── AROUND ───────────
342
343    pub fn around_all(&mut self, hook: Arc<dyn DogAroundHook<R, P>>) -> &mut Self {
344        self.around_all.push(hook);
345        self
346    }
347
348    pub fn around(
349        &mut self,
350        method: ServiceMethodKind,
351        hook: Arc<dyn DogAroundHook<R, P>>,
352    ) -> &mut Self {
353        self.around_by_method.entry(method).or_default().push(hook);
354        self
355    }
356
357    // ─────────── BEFORE ───────────
358
359    pub fn before_all(&mut self, hook: Arc<dyn DogBeforeHook<R, P>>) -> &mut Self {
360        self.before_all.push(hook);
361        self
362    }
363
364    pub fn before(
365        &mut self,
366        method: ServiceMethodKind,
367        hook: Arc<dyn DogBeforeHook<R, P>>,
368    ) -> &mut Self {
369        self.before_by_method.entry(method).or_default().push(hook);
370        self
371    }
372
373    pub fn before_create(&mut self, hook: Arc<dyn DogBeforeHook<R, P>>) -> &mut Self {
374        self.before(ServiceMethodKind::Create, hook)
375    }
376
377    pub fn before_find(&mut self, hook: Arc<dyn DogBeforeHook<R, P>>) -> &mut Self {
378        self.before(ServiceMethodKind::Find, hook)
379    }
380
381    pub fn before_get(&mut self, hook: Arc<dyn DogBeforeHook<R, P>>) -> &mut Self {
382        self.before(ServiceMethodKind::Get, hook)
383    }
384
385    pub fn before_update(&mut self, hook: Arc<dyn DogBeforeHook<R, P>>) -> &mut Self {
386        self.before(ServiceMethodKind::Update, hook)
387    }
388
389    pub fn before_patch(&mut self, hook: Arc<dyn DogBeforeHook<R, P>>) -> &mut Self {
390        self.before(ServiceMethodKind::Patch, hook)
391    }
392
393    pub fn before_remove(&mut self, hook: Arc<dyn DogBeforeHook<R, P>>) -> &mut Self {
394        self.before(ServiceMethodKind::Remove, hook)
395    }
396
397    // ─────────── AFTER ───────────
398
399    pub fn after_all(&mut self, hook: Arc<dyn DogAfterHook<R, P>>) -> &mut Self {
400        self.after_all.push(hook);
401        self
402    }
403
404    pub fn after(
405        &mut self,
406        method: ServiceMethodKind,
407        hook: Arc<dyn DogAfterHook<R, P>>,
408    ) -> &mut Self {
409        self.after_by_method.entry(method).or_default().push(hook);
410        self
411    }
412
413    pub fn after_create(&mut self, hook: Arc<dyn DogAfterHook<R, P>>) -> &mut Self {
414        self.after(ServiceMethodKind::Create, hook)
415    }
416
417    pub fn after_find(&mut self, hook: Arc<dyn DogAfterHook<R, P>>) -> &mut Self {
418        self.after(ServiceMethodKind::Find, hook)
419    }
420
421    // ─────────── ERROR ───────────
422
423    pub fn error_all(&mut self, hook: Arc<dyn DogErrorHook<R, P>>) -> &mut Self {
424        self.error_all.push(hook);
425        self
426    }
427
428    pub fn error(
429        &mut self,
430        method: ServiceMethodKind,
431        hook: Arc<dyn DogErrorHook<R, P>>,
432    ) -> &mut Self {
433        self.error_by_method.entry(method).or_default().push(hook);
434        self
435    }
436}