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/// Run the auth hook bound to `event`, if the `user` resource declares one.
218///
219/// Same contract as [`run`]: `Ok(None)` carries on, `Ok(Some(value))` is a
220/// replacement, `Err(response)` aborts the request. `resource` is the resource
221/// the endpoint operates on — `user` for register/login, `api_key` for key
222/// issuance — but the hook is always looked up on the `user` model, which is
223/// the resource the auth endpoints belong to.
224pub 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
250/// Call `name` with `payload` and `context`, and interpret what comes back.
251async 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    // Move owned handles into the blocking worker; functions block on the DB.
280    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        // A hook that faulted must not abort the request with the caller's `400`;
302        // it is the hook that is broken, not the request.
303        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        // Reached only if the *host* side of the blocking closure panicked;
311        // panics inside the hook are caught before they cross the ABI.
312        Err(_) => {
313            tracing::error!(hook = %hook_name, "hook task panicked");
314            Err(error(500, "hook failed"))
315        }
316    }
317}
318
319/// Interpret what a hook returned. See the module docs for the protocol.
320fn 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
357/// A hook's replacement payload, which must stay a JSON object for the
358/// operations that write columns.
359pub 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        // `role` is the caller's primary one, unchanged; `roles` is every role
416        // they hold, which is what a `role:` permission is checked against.
417        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        // A status outside 4xx/5xx (or a missing one) falls back to 400.
506        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}