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}