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