1use std::collections::HashMap;
33
34use apiplant_auth::Principal;
35use apiplant_core::{AuthEvent, HookEvent, Resource};
36use ntex::web::{HttpRequest, HttpResponse};
37use serde_json::{json, Value};
38use uuid::Uuid;
39
40use crate::functions::HostBridge;
41use crate::response::error;
42use crate::state::AppState;
43
44#[derive(Debug, Clone)]
48pub struct HookRequest {
49 url: String,
50 method: String,
51 query: HashMap<String, String>,
52 authenticated: bool,
53 principal_id: Option<String>,
54 organization_id: Option<String>,
55 role: Option<String>,
57 roles: Vec<String>,
60 record_id: Option<String>,
61}
62
63impl HookRequest {
64 pub fn new(
66 req: &HttpRequest,
67 query: &HashMap<String, String>,
68 principal: Option<&Principal>,
69 active_org: Option<Uuid>,
70 ) -> Self {
71 HookRequest {
72 url: req.uri().to_string(),
73 method: req.method().to_string(),
74 query: query.clone(),
75 authenticated: principal.is_some(),
76 principal_id: principal.map(|p| p.user_id.to_string()),
77 organization_id: active_org.map(|org| org.to_string()),
78 role: principal
79 .zip(active_org)
80 .and_then(|(p, org)| p.role_in(org))
81 .map(str::to_string),
82 roles: principal
86 .zip(active_org)
87 .map(|(p, org)| p.roles_in(org).to_vec())
88 .unwrap_or_default(),
89 record_id: None,
90 }
91 }
92
93 pub fn with_record(mut self, id: Uuid) -> Self {
95 self.record_id = Some(id.to_string());
96 self
97 }
98}
99
100fn context_json(
106 resource: &Resource,
107 event: HookEvent,
108 request: &HookRequest,
109 payload: &Value,
110) -> String {
111 let slot = match event {
112 HookEvent::BeforeCreate | HookEvent::BeforeUpdate => "data",
113 HookEvent::AfterList => "rows",
114 _ => "row",
115 };
116 describe(
117 &resource.meta.name,
118 event.as_str(),
119 event.action(),
120 event.phase(),
121 request,
122 slot,
123 payload,
124 )
125}
126
127fn auth_context_json(
133 resource: &Resource,
134 event: AuthEvent,
135 request: &HookRequest,
136 payload: &Value,
137) -> String {
138 let slot = match event {
139 AuthEvent::AfterRegister | AuthEvent::AfterApiKey => "row",
142 _ => "data",
143 };
144 describe(
145 &resource.meta.name,
146 event.as_str(),
147 event.action(),
148 event.phase(),
149 request,
150 slot,
151 payload,
152 )
153}
154
155fn describe(
158 resource: &str,
159 event: &str,
160 action: &str,
161 phase: &str,
162 request: &HookRequest,
163 slot: &str,
164 payload: &Value,
165) -> String {
166 let mut context = json!({
167 "event": event,
168 "action": action,
169 "phase": phase,
170 "resource": resource,
171 "url": request.url,
172 "method": request.method,
173 "query": request.query,
174 "authenticated": request.authenticated,
175 "principal_id": request.principal_id,
176 "organization_id": request.organization_id,
177 "role": request.role,
178 "roles": request.roles,
179 "record_id": request.record_id,
180 "data": Value::Null,
181 "row": Value::Null,
182 "rows": Value::Null,
183 });
184 context[slot] = payload.clone();
185 context.to_string()
186}
187
188pub async fn run(
195 state: &AppState,
196 resource: &Resource,
197 event: HookEvent,
198 request: &HookRequest,
199 payload: Value,
200) -> Result<Option<Value>, HttpResponse> {
201 let Some(name) = resource.hook(event) else {
202 return Ok(None);
203 };
204 let context = context_json(resource, event, request, &payload);
205 invoke(
206 state,
207 resource,
208 event.as_str(),
209 name,
210 request,
211 context,
212 payload,
213 )
214 .await
215}
216
217pub async fn announce(
233 state: &AppState,
234 resource: &Resource,
235 event: HookEvent,
236 request: &HookRequest,
237 row: &Value,
238) {
239 let Some(topic) = resource.publish.get(event) else {
240 return;
241 };
242 let published_by = request.principal_id.clone().unwrap_or_default();
243
244 if let Err(error) = state.queue.publish(topic, row, &published_by).await {
245 tracing::error!(
246 resource = %resource.meta.name,
247 event = event.as_str(),
248 topic,
249 %error,
250 "could not publish the message this write declares — the write itself succeeded"
251 );
252 }
253}
254
255pub async fn run_auth(
263 state: &AppState,
264 resource: &Resource,
265 event: AuthEvent,
266 request: &HookRequest,
267 payload: Value,
268) -> Result<Option<Value>, HttpResponse> {
269 let Some(user) = state.app.resources.get("user") else {
270 return Ok(None);
271 };
272 let Some(name) = user.auth_hook(event) else {
273 return Ok(None);
274 };
275 let context = auth_context_json(resource, event, request, &payload);
276 invoke(
277 state,
278 resource,
279 event.as_str(),
280 name,
281 request,
282 context,
283 payload,
284 )
285 .await
286}
287
288async fn invoke(
290 state: &AppState,
291 resource: &Resource,
292 event: &str,
293 name: &str,
294 request: &HookRequest,
295 context: String,
296 payload: Value,
297) -> Result<Option<Value>, HttpResponse> {
298 if state.functions.get(name).is_none() {
299 tracing::error!(
300 resource = %resource.meta.name,
301 hook = event,
302 function = name,
303 "hook function is not loaded"
304 );
305 return Err(error(
306 500,
307 format!(
308 "`{}` declares a `{event}` hook on a function `{name}` that is not loaded",
309 resource.meta.name,
310 ),
311 ));
312 }
313
314 let input = payload.to_string();
315 let principal_id = request.principal_id.clone().unwrap_or_default();
316
317 let functions = state.functions.clone();
319 let db = state.db.clone();
320 let mailer = state.mailer.clone();
321 let cache = state.cache.clone();
322 let payments = state.payments.clone();
323 let ai = state.ai.clone();
324 let queue = state.queue.clone();
325 let handle = tokio::runtime::Handle::current();
326 let name = name.to_string();
327 let hook_name = name.clone();
328
329 let result = tokio::task::spawn_blocking(move || {
330 let f = functions.get(&name).expect("checked above");
331 let bridge = HostBridge::new(db, handle, f.config_json.clone(), principal_id)
332 .with_services(mailer, cache, payments, ai)
333 .with_queue(queue)
334 .with_hook(context);
335 f.invoke(bridge, &input)
336 })
337 .await;
338
339 match result {
340 Ok(Ok(raw)) => outcome(&raw, &hook_name),
341 Ok(Err(message)) => match message.strip_prefix(apiplant_abi::INTERNAL_ERROR_PREFIX) {
344 Some(detail) => {
345 tracing::error!(hook = %hook_name, detail, "hook faulted");
346 Err(error(500, "hook failed"))
347 }
348 None => Err(error(400, message)),
349 },
350 Err(_) => {
353 tracing::error!(hook = %hook_name, "hook task panicked");
354 Err(error(500, "hook failed"))
355 }
356 }
357}
358
359fn outcome(raw: &str, hook_name: &str) -> Result<Option<Value>, HttpResponse> {
361 let value: Value = match serde_json::from_str(raw) {
362 Ok(v) => v,
363 Err(e) => {
364 tracing::error!(hook = %hook_name, error = %e, "hook returned invalid JSON");
365 return Err(error(
366 500,
367 format!("hook `{hook_name}` returned invalid JSON"),
368 ));
369 }
370 };
371 let Some(object) = value.as_object() else {
372 return Ok(None);
373 };
374 if let Some(rejection) = object.get("error") {
375 let (status, message) = match rejection {
376 Value::String(message) => (400, message.clone()),
377 Value::Object(details) => (
378 details
379 .get("status")
380 .and_then(Value::as_u64)
381 .and_then(|s| u16::try_from(s).ok())
382 .filter(|s| (400..=599).contains(s))
383 .unwrap_or(400),
384 details
385 .get("message")
386 .and_then(Value::as_str)
387 .unwrap_or("rejected by hook")
388 .to_string(),
389 ),
390 other => (400, other.to_string()),
391 };
392 return Err(error(status, message));
393 }
394 Ok(object.get("data").cloned())
395}
396
397pub fn replacement_object(
400 replacement: Value,
401 hook_name: &str,
402) -> Result<serde_json::Map<String, Value>, HttpResponse> {
403 match replacement {
404 Value::Object(map) => Ok(map),
405 _ => Err(error(
406 500,
407 format!("hook `{hook_name}` replaced the body with a non-object value"),
408 )),
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn parse_resource(src: &str) -> Resource {
417 let resource: Resource = toml::from_str(src).unwrap();
418 resource.validate().unwrap();
419 resource
420 }
421
422 fn request() -> HookRequest {
423 HookRequest {
424 url: "/api/post?draft=true".into(),
425 method: "POST".into(),
426 query: HashMap::from([("draft".to_string(), "true".to_string())]),
427 authenticated: true,
428 principal_id: Some("11111111-1111-1111-1111-111111111111".into()),
429 organization_id: Some("22222222-2222-2222-2222-222222222222".into()),
430 role: Some("support".into()),
431 roles: vec!["support".into(), "billing".into()],
432 record_id: None,
433 }
434 }
435
436 #[test]
437 fn context_describes_the_event_and_the_caller() {
438 let resource = parse_resource("[resource]\nname = \"post\"\n");
439 let raw = context_json(
440 &resource,
441 HookEvent::BeforeCreate,
442 &request(),
443 &json!({ "title": "Draft" }),
444 );
445 let context: Value = serde_json::from_str(&raw).unwrap();
446
447 assert_eq!(context["event"], "before_create");
448 assert_eq!(context["action"], "create");
449 assert_eq!(context["phase"], "before");
450 assert_eq!(context["resource"], "post");
451 assert_eq!(context["url"], "/api/post?draft=true");
452 assert_eq!(context["method"], "POST");
453 assert_eq!(context["query"]["draft"], "true");
454 assert_eq!(context["authenticated"], true);
455 assert_eq!(context["role"], "support");
458 assert_eq!(context["roles"][0], "support");
459 assert_eq!(context["roles"][1], "billing");
460 assert!(context["record_id"].is_null());
461 }
462
463 #[test]
464 fn payload_lands_in_the_slot_the_event_implies() {
465 let resource = parse_resource("[resource]\nname = \"post\"\n");
466 let row = json!({ "id": "abc", "title": "Hi" });
467
468 let created: Value = serde_json::from_str(&context_json(
469 &resource,
470 HookEvent::AfterCreate,
471 &request(),
472 &row,
473 ))
474 .unwrap();
475 assert_eq!(created["row"], row);
476 assert!(created["data"].is_null());
477 assert!(created["rows"].is_null());
478
479 let listed: Value = serde_json::from_str(&context_json(
480 &resource,
481 HookEvent::AfterList,
482 &request(),
483 &json!([row]),
484 ))
485 .unwrap();
486 assert_eq!(listed["rows"].as_array().unwrap().len(), 1);
487 assert!(listed["row"].is_null());
488
489 let submitted: Value = serde_json::from_str(&context_json(
490 &resource,
491 HookEvent::BeforeUpdate,
492 &request(),
493 &json!({ "title": "Edited" }),
494 ))
495 .unwrap();
496 assert_eq!(submitted["data"]["title"], "Edited");
497 assert!(submitted["row"].is_null());
498 }
499
500 #[test]
501 fn record_id_is_carried_for_single_record_operations() {
502 let resource = parse_resource("[resource]\nname = \"post\"\n");
503 let id = Uuid::new_v4();
504 let raw = context_json(
505 &resource,
506 HookEvent::BeforeDelete,
507 &request().with_record(id),
508 &json!({}),
509 );
510 let context: Value = serde_json::from_str(&raw).unwrap();
511 assert_eq!(context["record_id"], id.to_string());
512 }
513
514 #[test]
515 fn outcome_continues_on_empty_or_unrecognised_replies() {
516 assert!(outcome("{}", "h").unwrap().is_none());
517 assert!(outcome("null", "h").unwrap().is_none());
518 assert!(outcome("\"ok\"", "h").unwrap().is_none());
519 assert!(outcome(r#"{"logged":true}"#, "h").unwrap().is_none());
520 }
521
522 #[test]
523 fn outcome_extracts_replacement_data() {
524 let replacement = outcome(r#"{"data":{"title":"clean"}}"#, "h")
525 .unwrap()
526 .unwrap();
527 assert_eq!(replacement["title"], "clean");
528
529 let rows = outcome(r#"{"data":[{"id":"a"}]}"#, "h").unwrap().unwrap();
530 assert_eq!(rows.as_array().unwrap().len(), 1);
531 }
532
533 #[test]
534 fn outcome_maps_rejections_to_http_statuses() {
535 let err = outcome(
536 r#"{"error":{"status":422,"message":"title required"}}"#,
537 "h",
538 )
539 .unwrap_err();
540 assert_eq!(err.status().as_u16(), 422);
541
542 let plain = outcome(r#"{"error":"nope"}"#, "h").unwrap_err();
543 assert_eq!(plain.status().as_u16(), 400);
544
545 let odd = outcome(r#"{"error":{"status":200,"message":"x"}}"#, "h").unwrap_err();
547 assert_eq!(odd.status().as_u16(), 400);
548 let bare = outcome(r#"{"error":{}}"#, "h").unwrap_err();
549 assert_eq!(bare.status().as_u16(), 400);
550 }
551
552 #[test]
553 fn outcome_rejects_malformed_json_with_a_500() {
554 let err = outcome("{not json", "h").unwrap_err();
555 assert_eq!(err.status().as_u16(), 500);
556 }
557
558 #[test]
559 fn replacement_must_be_an_object_for_writes() {
560 let map = replacement_object(json!({ "title": "x" }), "h").unwrap();
561 assert_eq!(map["title"], "x");
562
563 let err = replacement_object(json!([1, 2]), "h").unwrap_err();
564 assert_eq!(err.status().as_u16(), 500);
565 }
566}