Skip to main content

cloudillo_action/
hooks.rs

1//! Hook implementation types and registry for hybrid DSL + native execution
2
3use crate::prelude::*;
4use cloudillo_core::app::AppState;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use super::dsl::types::Operation;
12
13/// Result type for hook functions
14pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
15
16/// Native hook function type
17/// Takes AppState and HookContext, returns a Future resolving to HookResult
18pub type HookFunction = Arc<
19	dyn Fn(Arc<AppState>, HookContext) -> BoxFuture<'static, ClResult<HookResult>> + Send + Sync,
20>;
21
22/// Represents how a hook is implemented
23#[derive(Clone, Default)]
24pub enum HookImplementation {
25	/// DSL-based hook (declarative JSON operations)
26	Dsl(Vec<Operation>),
27
28	/// Native Rust async function implementation
29	Native(HookFunction),
30
31	/// Both DSL and native (DSL runs first, then native)
32	Hybrid { dsl: Vec<Operation>, native: HookFunction },
33
34	/// No hook defined
35	#[default]
36	None,
37}
38
39// Custom serialization for HookImplementation
40// Only serializes DSL operations as Option<Vec<Operation>>, not native functions
41impl Serialize for HookImplementation {
42	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
43	where
44		S: serde::Serializer,
45	{
46		match self {
47			HookImplementation::Dsl(ops) => ops.serialize(serializer),
48			HookImplementation::None => None::<Vec<Operation>>.serialize(serializer),
49			HookImplementation::Native(_) => {
50				// Can't serialize native functions, treat as None
51				None::<Vec<Operation>>.serialize(serializer)
52			}
53			HookImplementation::Hybrid { dsl, .. } => {
54				// Only serialize DSL portion, drop native
55				dsl.serialize(serializer)
56			}
57		}
58	}
59}
60
61// Custom deserialization for HookImplementation
62impl<'de> Deserialize<'de> for HookImplementation {
63	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
64	where
65		D: serde::Deserializer<'de>,
66	{
67		let ops: Option<Vec<Operation>> = Option::deserialize(deserializer)?;
68		match ops {
69			None => Ok(HookImplementation::None),
70			Some(ops) => Ok(HookImplementation::Dsl(ops)),
71		}
72	}
73}
74
75impl std::fmt::Debug for HookImplementation {
76	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77		match self {
78			Self::Dsl(ops) => f.debug_tuple("Dsl").field(ops).finish(),
79			Self::Native(_) => f.debug_tuple("Native").field(&"<function>").finish(),
80			Self::Hybrid { dsl, .. } => f
81				.debug_struct("Hybrid")
82				.field("dsl", dsl)
83				.field("native", &"<function>")
84				.finish(),
85			Self::None => write!(f, "None"),
86		}
87	}
88}
89
90impl HookImplementation {
91	/// Check if this hook is defined (not None)
92	pub fn is_some(&self) -> bool {
93		!matches!(self, HookImplementation::None)
94	}
95
96	/// Check if this hook is undefined
97	pub fn is_none(&self) -> bool {
98		matches!(self, HookImplementation::None)
99	}
100}
101
102/// Result returned by hook execution
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct HookResult {
105	/// Variables to merge back into context
106	pub vars: HashMap<String, serde_json::Value>,
107
108	/// Whether to continue processing (false = abort)
109	pub continue_processing: bool,
110
111	/// Optional early return value
112	pub return_value: Option<serde_json::Value>,
113}
114
115impl Default for HookResult {
116	fn default() -> Self {
117		Self { vars: HashMap::new(), continue_processing: true, return_value: None }
118	}
119}
120
121/// Hook type enumeration
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub enum HookType {
124	OnCreate,
125	OnReceive,
126	OnAccept,
127	OnReject,
128}
129
130impl HookType {
131	/// Get string representation of hook type
132	pub fn as_str(&self) -> &'static str {
133		match self {
134			HookType::OnCreate => "on_create",
135			HookType::OnReceive => "on_receive",
136			HookType::OnAccept => "on_accept",
137			HookType::OnReject => "on_reject",
138		}
139	}
140}
141
142/// Hook execution context
143#[derive(Debug, Clone)]
144pub struct HookContext {
145	// Action data
146	pub action_id: String,
147	pub r#type: String,
148	pub subtype: Option<String>,
149	pub issuer: String,
150	pub audience: Option<String>,
151	pub parent: Option<String>,
152	pub subject: Option<String>,
153	pub content: Option<serde_json::Value>,
154	pub attachments: Option<Vec<String>>,
155
156	// Timestamps
157	pub created_at: String,
158	pub expires_at: Option<String>,
159
160	// Context
161	pub tenant_id: i64,
162	pub tenant_tag: String,
163	pub tenant_type: String,
164
165	// Flags
166	pub is_inbound: bool,
167	pub is_outbound: bool,
168
169	// Client information
170	/// Client IP address (available for inbound actions)
171	pub client_address: Option<String>,
172
173	// Variables set by operations
174	pub vars: HashMap<String, serde_json::Value>,
175}
176
177impl HookContext {
178	/// Create a new HookContext builder
179	pub fn builder() -> HookContextBuilder {
180		HookContextBuilder::default()
181	}
182}
183
184/// Builder for HookContext with fluent API
185#[derive(Default)]
186pub struct HookContextBuilder {
187	action_id: String,
188	r#type: String,
189	subtype: Option<String>,
190	issuer: String,
191	audience: Option<String>,
192	parent: Option<String>,
193	subject: Option<String>,
194	content: Option<serde_json::Value>,
195	attachments: Option<Vec<String>>,
196	created_at: String,
197	expires_at: Option<String>,
198	tenant_id: i64,
199	tenant_tag: String,
200	tenant_type: String,
201	is_inbound: bool,
202	is_outbound: bool,
203	client_address: Option<String>,
204	vars: HashMap<String, serde_json::Value>,
205}
206
207impl HookContextBuilder {
208	/// Set action ID
209	pub fn action_id(mut self, id: impl Into<String>) -> Self {
210		self.action_id = id.into();
211		self
212	}
213
214	/// Set action type
215	pub fn action_type(mut self, t: impl Into<String>) -> Self {
216		self.r#type = t.into();
217		self
218	}
219
220	/// Set subtype
221	pub fn subtype(mut self, st: Option<String>) -> Self {
222		self.subtype = st;
223		self
224	}
225
226	/// Set issuer
227	pub fn issuer(mut self, i: impl Into<String>) -> Self {
228		self.issuer = i.into();
229		self
230	}
231
232	/// Set audience
233	pub fn audience(mut self, a: Option<String>) -> Self {
234		self.audience = a;
235		self
236	}
237
238	/// Set parent action ID
239	pub fn parent(mut self, p: Option<String>) -> Self {
240		self.parent = p;
241		self
242	}
243
244	/// Set subject
245	pub fn subject(mut self, s: Option<String>) -> Self {
246		self.subject = s;
247		self
248	}
249
250	/// Set content
251	pub fn content(mut self, c: Option<serde_json::Value>) -> Self {
252		self.content = c;
253		self
254	}
255
256	/// Set attachments
257	pub fn attachments(mut self, a: Option<Vec<String>>) -> Self {
258		self.attachments = a;
259		self
260	}
261
262	/// Set created_at timestamp
263	pub fn created_at(mut self, ts: impl Into<String>) -> Self {
264		self.created_at = ts.into();
265		self
266	}
267
268	/// Set expires_at timestamp
269	pub fn expires_at(mut self, ts: Option<String>) -> Self {
270		self.expires_at = ts;
271		self
272	}
273
274	/// Set tenant info
275	pub fn tenant(mut self, id: i64, tag: impl Into<String>, typ: impl Into<String>) -> Self {
276		self.tenant_id = id;
277		self.tenant_tag = tag.into();
278		self.tenant_type = typ.into();
279		self
280	}
281
282	/// Mark as inbound action
283	pub fn inbound(mut self) -> Self {
284		self.is_inbound = true;
285		self.is_outbound = false;
286		self
287	}
288
289	/// Mark as outbound action
290	pub fn outbound(mut self) -> Self {
291		self.is_inbound = false;
292		self.is_outbound = true;
293		self
294	}
295
296	/// Set client address
297	pub fn client_address(mut self, addr: Option<String>) -> Self {
298		self.client_address = addr;
299		self
300	}
301
302	/// Set variables
303	pub fn vars(mut self, vars: HashMap<String, serde_json::Value>) -> Self {
304		self.vars = vars;
305		self
306	}
307
308	/// Build the HookContext
309	pub fn build(self) -> HookContext {
310		HookContext {
311			action_id: self.action_id,
312			r#type: self.r#type,
313			subtype: self.subtype,
314			issuer: self.issuer,
315			audience: self.audience,
316			parent: self.parent,
317			subject: self.subject,
318			content: self.content,
319			attachments: self.attachments,
320			created_at: self.created_at,
321			expires_at: self.expires_at,
322			tenant_id: self.tenant_id,
323			tenant_tag: self.tenant_tag,
324			tenant_type: self.tenant_type,
325			is_inbound: self.is_inbound,
326			is_outbound: self.is_outbound,
327			client_address: self.client_address,
328			vars: self.vars,
329		}
330	}
331}
332
333/// All hooks for a specific action type
334pub struct ActionTypeHooks {
335	pub on_create: Option<HookFunction>,
336	pub on_receive: Option<HookFunction>,
337	pub on_accept: Option<HookFunction>,
338	pub on_reject: Option<HookFunction>,
339}
340
341/// Registry of native hook implementations
342pub struct HookRegistry {
343	hooks: HashMap<String, ActionTypeHooks>,
344}
345
346impl HookRegistry {
347	/// Create a new empty hook registry
348	pub fn new() -> Self {
349		Self { hooks: HashMap::new() }
350	}
351
352	/// Register a complete action type with all hooks
353	pub fn register_type(&mut self, type_name: &str, hooks: ActionTypeHooks) {
354		self.hooks.insert(type_name.to_string(), hooks);
355	}
356
357	/// Register a single hook for an action type
358	pub fn register_hook(&mut self, type_name: &str, hook_type: HookType, function: HookFunction) {
359		let entry = self.hooks.entry(type_name.to_string()).or_insert_with(|| ActionTypeHooks {
360			on_create: None,
361			on_receive: None,
362			on_accept: None,
363			on_reject: None,
364		});
365
366		match hook_type {
367			HookType::OnCreate => entry.on_create = Some(function),
368			HookType::OnReceive => entry.on_receive = Some(function),
369			HookType::OnAccept => entry.on_accept = Some(function),
370			HookType::OnReject => entry.on_reject = Some(function),
371		}
372	}
373
374	/// Get hook function if registered
375	pub fn get_hook(&self, type_name: &str, hook_type: HookType) -> Option<&HookFunction> {
376		self.hooks.get(type_name).and_then(|hooks| match hook_type {
377			HookType::OnCreate => hooks.on_create.as_ref(),
378			HookType::OnReceive => hooks.on_receive.as_ref(),
379			HookType::OnAccept => hooks.on_accept.as_ref(),
380			HookType::OnReject => hooks.on_reject.as_ref(),
381		})
382	}
383
384	/// Check if a hook is registered
385	pub fn has_hook(&self, type_name: &str, hook_type: HookType) -> bool {
386		self.get_hook(type_name, hook_type).is_some()
387	}
388
389	/// Get all registered action types
390	pub fn registered_types(&self) -> Vec<&str> {
391		self.hooks.keys().map(|s| s.as_str()).collect()
392	}
393}
394
395impl Default for HookRegistry {
396	fn default() -> Self {
397		Self::new()
398	}
399}
400
401#[cfg(test)]
402mod tests {
403	use super::*;
404
405	#[test]
406	fn test_hook_type_str_conversion() {
407		assert_eq!(HookType::OnCreate.as_str(), "on_create");
408		assert_eq!(HookType::OnReceive.as_str(), "on_receive");
409		assert_eq!(HookType::OnAccept.as_str(), "on_accept");
410		assert_eq!(HookType::OnReject.as_str(), "on_reject");
411	}
412
413	#[test]
414	fn test_hook_result_default() {
415		let result = HookResult::default();
416		assert!(result.vars.is_empty());
417		assert!(result.continue_processing);
418		assert!(result.return_value.is_none());
419	}
420
421	#[test]
422	fn test_hook_registry_create() {
423		let registry = HookRegistry::new();
424		assert!(registry.registered_types().is_empty());
425	}
426
427	#[test]
428	fn test_hook_registry_register_hook() {
429		let mut registry = HookRegistry::new();
430
431		// Create a dummy hook function
432		let hook: HookFunction = Arc::new(|_, _| Box::pin(async { Ok(HookResult::default()) }));
433
434		registry.register_hook("TEST", HookType::OnCreate, hook.clone());
435		assert!(registry.has_hook("TEST", HookType::OnCreate));
436		assert!(!registry.has_hook("TEST", HookType::OnReceive));
437	}
438
439	#[test]
440	fn test_hook_implementation_default() {
441		let impl_hook = HookImplementation::default();
442		assert!(matches!(impl_hook, HookImplementation::None));
443	}
444
445	#[test]
446	fn test_hook_context_creation() {
447		let context = HookContext {
448			action_id: "a1~test".to_string(),
449			r#type: "POST".to_string(),
450			subtype: None,
451			issuer: "user1".to_string(),
452			audience: None,
453			parent: None,
454			subject: None,
455			content: None,
456			attachments: None,
457			created_at: "2025-11-09T00:00:00Z".to_string(),
458			expires_at: None,
459			tenant_id: 1,
460			tenant_tag: "dev".to_string(),
461			tenant_type: "user".to_string(),
462			is_inbound: false,
463			is_outbound: true,
464			client_address: None,
465			vars: HashMap::new(),
466		};
467
468		assert_eq!(context.action_id, "a1~test");
469		assert_eq!(context.r#type, "POST");
470		assert_eq!(context.issuer, "user1");
471	}
472}
473
474// vim: ts=4