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 run_auth(
225 state: &AppState,
226 resource: &Resource,
227 event: AuthEvent,
228 request: &HookRequest,
229 payload: Value,
230) -> Result<Option<Value>, HttpResponse> {
231 let Some(user) = state.app.resources.get("user") else {
232 return Ok(None);
233 };
234 let Some(name) = user.auth_hook(event) else {
235 return Ok(None);
236 };
237 let context = auth_context_json(resource, event, request, &payload);
238 invoke(
239 state,
240 resource,
241 event.as_str(),
242 name,
243 request,
244 context,
245 payload,
246 )
247 .await
248}
249
250async fn invoke(
252 state: &AppState,
253 resource: &Resource,
254 event: &str,
255 name: &str,
256 request: &HookRequest,
257 context: String,
258 payload: Value,
259) -> Result<Option<Value>, HttpResponse> {
260 if state.functions.get(name).is_none() {
261 tracing::error!(
262 resource = %resource.meta.name,
263 hook = event,
264 function = name,
265 "hook function is not loaded"
266 );
267 return Err(error(
268 500,
269 format!(
270 "`{}` declares a `{event}` hook on a function `{name}` that is not loaded",
271 resource.meta.name,
272 ),
273 ));
274 }
275
276 let input = payload.to_string();
277 let principal_id = request.principal_id.clone().unwrap_or_default();
278
279 let functions = state.functions.clone();
281 let db = state.db.clone();
282 let mailer = state.mailer.clone();
283 let cache = state.cache.clone();
284 let payments = state.payments.clone();
285 let ai = state.ai.clone();
286 let handle = tokio::runtime::Handle::current();
287 let name = name.to_string();
288 let hook_name = name.clone();
289
290 let result = tokio::task::spawn_blocking(move || {
291 let f = functions.get(&name).expect("checked above");
292 let bridge = HostBridge::new(db, handle, f.config_json.clone(), principal_id)
293 .with_services(mailer, cache, payments, ai)
294 .with_hook(context);
295 f.invoke(bridge, &input)
296 })
297 .await;
298
299 match result {
300 Ok(Ok(raw)) => outcome(&raw, &hook_name),
301 Ok(Err(message)) => match message.strip_prefix(apiplant_abi::INTERNAL_ERROR_PREFIX) {
304 Some(detail) => {
305 tracing::error!(hook = %hook_name, detail, "hook faulted");
306 Err(error(500, "hook failed"))
307 }
308 None => Err(error(400, message)),
309 },
310 Err(_) => {
313 tracing::error!(hook = %hook_name, "hook task panicked");
314 Err(error(500, "hook failed"))
315 }
316 }
317}
318
319fn outcome(raw: &str, hook_name: &str) -> Result<Option<Value>, HttpResponse> {
321 let value: Value = match serde_json::from_str(raw) {
322 Ok(v) => v,
323 Err(e) => {
324 tracing::error!(hook = %hook_name, error = %e, "hook returned invalid JSON");
325 return Err(error(
326 500,
327 format!("hook `{hook_name}` returned invalid JSON"),
328 ));
329 }
330 };
331 let Some(object) = value.as_object() else {
332 return Ok(None);
333 };
334 if let Some(rejection) = object.get("error") {
335 let (status, message) = match rejection {
336 Value::String(message) => (400, message.clone()),
337 Value::Object(details) => (
338 details
339 .get("status")
340 .and_then(Value::as_u64)
341 .and_then(|s| u16::try_from(s).ok())
342 .filter(|s| (400..=599).contains(s))
343 .unwrap_or(400),
344 details
345 .get("message")
346 .and_then(Value::as_str)
347 .unwrap_or("rejected by hook")
348 .to_string(),
349 ),
350 other => (400, other.to_string()),
351 };
352 return Err(error(status, message));
353 }
354 Ok(object.get("data").cloned())
355}
356
357pub fn replacement_object(
360 replacement: Value,
361 hook_name: &str,
362) -> Result<serde_json::Map<String, Value>, HttpResponse> {
363 match replacement {
364 Value::Object(map) => Ok(map),
365 _ => Err(error(
366 500,
367 format!("hook `{hook_name}` replaced the body with a non-object value"),
368 )),
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn parse_resource(src: &str) -> Resource {
377 let resource: Resource = toml::from_str(src).unwrap();
378 resource.validate().unwrap();
379 resource
380 }
381
382 fn request() -> HookRequest {
383 HookRequest {
384 url: "/api/post?draft=true".into(),
385 method: "POST".into(),
386 query: HashMap::from([("draft".to_string(), "true".to_string())]),
387 authenticated: true,
388 principal_id: Some("11111111-1111-1111-1111-111111111111".into()),
389 organization_id: Some("22222222-2222-2222-2222-222222222222".into()),
390 role: Some("support".into()),
391 roles: vec!["support".into(), "billing".into()],
392 record_id: None,
393 }
394 }
395
396 #[test]
397 fn context_describes_the_event_and_the_caller() {
398 let resource = parse_resource("[resource]\nname = \"post\"\n");
399 let raw = context_json(
400 &resource,
401 HookEvent::BeforeCreate,
402 &request(),
403 &json!({ "title": "Draft" }),
404 );
405 let context: Value = serde_json::from_str(&raw).unwrap();
406
407 assert_eq!(context["event"], "before_create");
408 assert_eq!(context["action"], "create");
409 assert_eq!(context["phase"], "before");
410 assert_eq!(context["resource"], "post");
411 assert_eq!(context["url"], "/api/post?draft=true");
412 assert_eq!(context["method"], "POST");
413 assert_eq!(context["query"]["draft"], "true");
414 assert_eq!(context["authenticated"], true);
415 assert_eq!(context["role"], "support");
418 assert_eq!(context["roles"][0], "support");
419 assert_eq!(context["roles"][1], "billing");
420 assert!(context["record_id"].is_null());
421 }
422
423 #[test]
424 fn payload_lands_in_the_slot_the_event_implies() {
425 let resource = parse_resource("[resource]\nname = \"post\"\n");
426 let row = json!({ "id": "abc", "title": "Hi" });
427
428 let created: Value = serde_json::from_str(&context_json(
429 &resource,
430 HookEvent::AfterCreate,
431 &request(),
432 &row,
433 ))
434 .unwrap();
435 assert_eq!(created["row"], row);
436 assert!(created["data"].is_null());
437 assert!(created["rows"].is_null());
438
439 let listed: Value = serde_json::from_str(&context_json(
440 &resource,
441 HookEvent::AfterList,
442 &request(),
443 &json!([row]),
444 ))
445 .unwrap();
446 assert_eq!(listed["rows"].as_array().unwrap().len(), 1);
447 assert!(listed["row"].is_null());
448
449 let submitted: Value = serde_json::from_str(&context_json(
450 &resource,
451 HookEvent::BeforeUpdate,
452 &request(),
453 &json!({ "title": "Edited" }),
454 ))
455 .unwrap();
456 assert_eq!(submitted["data"]["title"], "Edited");
457 assert!(submitted["row"].is_null());
458 }
459
460 #[test]
461 fn record_id_is_carried_for_single_record_operations() {
462 let resource = parse_resource("[resource]\nname = \"post\"\n");
463 let id = Uuid::new_v4();
464 let raw = context_json(
465 &resource,
466 HookEvent::BeforeDelete,
467 &request().with_record(id),
468 &json!({}),
469 );
470 let context: Value = serde_json::from_str(&raw).unwrap();
471 assert_eq!(context["record_id"], id.to_string());
472 }
473
474 #[test]
475 fn outcome_continues_on_empty_or_unrecognised_replies() {
476 assert!(outcome("{}", "h").unwrap().is_none());
477 assert!(outcome("null", "h").unwrap().is_none());
478 assert!(outcome("\"ok\"", "h").unwrap().is_none());
479 assert!(outcome(r#"{"logged":true}"#, "h").unwrap().is_none());
480 }
481
482 #[test]
483 fn outcome_extracts_replacement_data() {
484 let replacement = outcome(r#"{"data":{"title":"clean"}}"#, "h")
485 .unwrap()
486 .unwrap();
487 assert_eq!(replacement["title"], "clean");
488
489 let rows = outcome(r#"{"data":[{"id":"a"}]}"#, "h").unwrap().unwrap();
490 assert_eq!(rows.as_array().unwrap().len(), 1);
491 }
492
493 #[test]
494 fn outcome_maps_rejections_to_http_statuses() {
495 let err = outcome(
496 r#"{"error":{"status":422,"message":"title required"}}"#,
497 "h",
498 )
499 .unwrap_err();
500 assert_eq!(err.status().as_u16(), 422);
501
502 let plain = outcome(r#"{"error":"nope"}"#, "h").unwrap_err();
503 assert_eq!(plain.status().as_u16(), 400);
504
505 let odd = outcome(r#"{"error":{"status":200,"message":"x"}}"#, "h").unwrap_err();
507 assert_eq!(odd.status().as_u16(), 400);
508 let bare = outcome(r#"{"error":{}}"#, "h").unwrap_err();
509 assert_eq!(bare.status().as_u16(), 400);
510 }
511
512 #[test]
513 fn outcome_rejects_malformed_json_with_a_500() {
514 let err = outcome("{not json", "h").unwrap_err();
515 assert_eq!(err.status().as_u16(), 500);
516 }
517
518 #[test]
519 fn replacement_must_be_an_object_for_writes() {
520 let map = replacement_object(json!({ "title": "x" }), "h").unwrap();
521 assert_eq!(map["title"], "x");
522
523 let err = replacement_object(json!([1, 2]), "h").unwrap_err();
524 assert_eq!(err.status().as_u16(), 500);
525 }
526}