Skip to main content

apiplant_server/
hooks.rs

1//! Resource lifecycle hooks: running a named function around a CRUD operation.
2//!
3//! A resource declares hooks in its `[hooks]` section, one function name per
4//! [`HookEvent`]:
5//!
6//! ```toml
7//! [hooks]
8//! before_create = "validate_post"
9//! after_create  = "notify_slack"
10//! ```
11//!
12//! `before_*` hooks run after the permission check but before the database is
13//! touched; `after_*` hooks run once the operation succeeded. Both receive the
14//! operation's *payload* as their input (the submitted body, the row, or the
15//! list of rows) plus a [context object](context_json) describing the event,
16//! the request URL, the caller's auth status and the row(s) in play — reachable
17//! from a function through `ctx.hook()`.
18//!
19//! What a hook returns decides what happens next:
20//!
21//! | Return value | Effect |
22//! |--------------|--------|
23//! | `{}` / `null` / anything else | continue unchanged |
24//! | `{"data": …}` | replace the payload (`before_create`/`before_update`), answer the request without touching the database (`before_read`/`before_list`), or replace the response body (`after_*`) |
25//! | `{"error": {"status": 422, "message": "…"}}` | abort the request with that status |
26//! | `Err(msg)` from the handler | abort with `400` and `msg` |
27//! | a panic in the handler | abort with `500`; the detail is logged, not returned |
28//!
29//! Hooks are called regardless of a function's `visibility`, so a
30//! `Private` function — invisible over HTTP — is the natural way to write one.
31
32use 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/// The request-scoped facts every hook sees, independent of the event.
45///
46/// Built once per handler and reused by that handler's before/after hooks.
47#[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    /// The caller's primary role in the active organisation.
56    role: Option<String>,
57    /// Every role they hold there — what a `role:` permission is checked
58    /// against, since one member can hold several and an admin holds all.
59    roles: Vec<String>,
60    record_id: Option<String>,
61}
62
63impl HookRequest {
64    /// Capture the current request and the caller's resolved identity.
65    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            // `role` is the caller's primary one and stays exactly what it was;
83            // `roles` is every role they hold, which is what a permission is
84            // actually checked against.
85            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    /// Attach the record id from the URL, for single-record operations.
94    pub fn with_record(mut self, id: Uuid) -> Self {
95        self.record_id = Some(id.to_string());
96        self
97    }
98}
99
100/// Build the JSON context handed to the hook function.
101///
102/// The payload is mirrored into the slot the event implies — `data` for the
103/// `before_create`/`before_update` body, `rows` for `after_list`, `row`
104/// everywhere else — so a hook can read it without knowing how it was invoked.
105fn 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
127/// Build the JSON context handed to an auth hook.
128///
129/// Same shape as a resource hook's context, so a function can be written once
130/// and bound to either: a row the endpoint produced arrives in `row`, and
131/// anything else — a submitted body, a login's outcome — in `data`.
132fn auth_context_json(
133    resource: &Resource,
134    event: AuthEvent,
135    request: &HookRequest,
136    payload: &Value,
137) -> String {
138    let slot = match event {
139        // `after_login` reports on an attempt rather than handing back a row,
140        // and a failed attempt has no row to hand back at all.
141        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
155/// The context object shared by both hook families, with `payload` dropped into
156/// `slot`.
157fn 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
188/// Run the hook bound to `event`, if the resource declares one.
189///
190/// Returns `Ok(None)` to carry on unchanged, `Ok(Some(value))` when the hook
191/// replaced the payload/response, and `Err(response)` when it aborted the
192/// request. A declared hook whose function isn't loaded fails closed with a
193/// `500` — silently skipping it would bypass validation.
194pub 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
217/// Publish the topic a resource declares for `event`, if it declares one.
218///
219/// The row is the message. A subscriber to `order.placed` gets the order,
220/// exactly as the API would have returned it — so the handler is an ordinary
221/// function over an ordinary object, and can be tested by posting one to it.
222///
223/// **Never fails the request.** By the time this runs the row is committed and
224/// the caller's response is decided; turning "the message could not be queued"
225/// into a 500 would tell them their write failed when it did not. It is logged
226/// instead, at `error`, because a dropped announcement is a real problem — just
227/// not theirs.
228///
229/// Deliberately after the `after_*` hook rather than before it: a hook may still
230/// replace or reject the response, and announcing a create that the hook then
231/// turned into a 409 would be announcing something that did not happen.
232pub 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
255/// Run the auth hook bound to `event`, if the `user` resource declares one.
256///
257/// Same contract as [`run`]: `Ok(None)` carries on, `Ok(Some(value))` is a
258/// replacement, `Err(response)` aborts the request. `resource` is the resource
259/// the endpoint operates on — `user` for register/login, `api_key` for key
260/// issuance — but the hook is always looked up on the `user` model, which is
261/// the resource the auth endpoints belong to.
262pub 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
288/// Call `name` with `payload` and `context`, and interpret what comes back.
289async 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    // Move owned handles into the blocking worker; functions block on the DB.
318    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        // A hook that faulted must not abort the request with the caller's `400`;
342        // it is the hook that is broken, not the request.
343        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        // Reached only if the *host* side of the blocking closure panicked;
351        // panics inside the hook are caught before they cross the ABI.
352        Err(_) => {
353            tracing::error!(hook = %hook_name, "hook task panicked");
354            Err(error(500, "hook failed"))
355        }
356    }
357}
358
359/// Interpret what a hook returned. See the module docs for the protocol.
360fn 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
397/// A hook's replacement payload, which must stay a JSON object for the
398/// operations that write columns.
399pub 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        // `role` is the caller's primary one, unchanged; `roles` is every role
456        // they hold, which is what a `role:` permission is checked against.
457        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        // A status outside 4xx/5xx (or a missing one) falls back to 400.
546        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}