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`) or 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 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        // A hook that faulted must not abort the request with the caller's `400`;
300        // it is the hook that is broken, not the request.
301        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        // Reached only if the *host* side of the blocking closure panicked;
309        // panics inside the hook are caught before they cross the ABI.
310        Err(_) => {
311            tracing::error!(hook = %hook_name, "hook task panicked");
312            Err(error(500, "hook failed"))
313        }
314    }
315}
316
317/// Interpret what a hook returned. See the module docs for the protocol.
318fn 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
355/// A hook's replacement payload, which must stay a JSON object for the
356/// operations that write columns.
357pub 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        // `role` is the caller's primary one, unchanged; `roles` is every role
414        // they hold, which is what a `role:` permission is checked against.
415        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        // A status outside 4xx/5xx (or a missing one) falls back to 400.
504        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}