use std::collections::HashMap;
use apiplant_auth::Principal;
use apiplant_core::{AuthEvent, HookEvent, Resource};
use ntex::web::{HttpRequest, HttpResponse};
use serde_json::{json, Value};
use uuid::Uuid;
use crate::functions::HostBridge;
use crate::response::error;
use crate::state::AppState;
#[derive(Debug, Clone)]
pub struct HookRequest {
url: String,
method: String,
query: HashMap<String, String>,
authenticated: bool,
principal_id: Option<String>,
organization_id: Option<String>,
role: Option<String>,
roles: Vec<String>,
record_id: Option<String>,
}
impl HookRequest {
pub fn new(
req: &HttpRequest,
query: &HashMap<String, String>,
principal: Option<&Principal>,
active_org: Option<Uuid>,
) -> Self {
HookRequest {
url: req.uri().to_string(),
method: req.method().to_string(),
query: query.clone(),
authenticated: principal.is_some(),
principal_id: principal.map(|p| p.user_id.to_string()),
organization_id: active_org.map(|org| org.to_string()),
role: principal
.zip(active_org)
.and_then(|(p, org)| p.role_in(org))
.map(str::to_string),
roles: principal
.zip(active_org)
.map(|(p, org)| p.roles_in(org).to_vec())
.unwrap_or_default(),
record_id: None,
}
}
pub fn with_record(mut self, id: Uuid) -> Self {
self.record_id = Some(id.to_string());
self
}
}
fn context_json(
resource: &Resource,
event: HookEvent,
request: &HookRequest,
payload: &Value,
) -> String {
let slot = match event {
HookEvent::BeforeCreate | HookEvent::BeforeUpdate => "data",
HookEvent::AfterList => "rows",
_ => "row",
};
describe(
&resource.meta.name,
event.as_str(),
event.action(),
event.phase(),
request,
slot,
payload,
)
}
fn auth_context_json(
resource: &Resource,
event: AuthEvent,
request: &HookRequest,
payload: &Value,
) -> String {
let slot = match event {
AuthEvent::AfterRegister | AuthEvent::AfterApiKey => "row",
_ => "data",
};
describe(
&resource.meta.name,
event.as_str(),
event.action(),
event.phase(),
request,
slot,
payload,
)
}
fn describe(
resource: &str,
event: &str,
action: &str,
phase: &str,
request: &HookRequest,
slot: &str,
payload: &Value,
) -> String {
let mut context = json!({
"event": event,
"action": action,
"phase": phase,
"resource": resource,
"url": request.url,
"method": request.method,
"query": request.query,
"authenticated": request.authenticated,
"principal_id": request.principal_id,
"organization_id": request.organization_id,
"role": request.role,
"roles": request.roles,
"record_id": request.record_id,
"data": Value::Null,
"row": Value::Null,
"rows": Value::Null,
});
context[slot] = payload.clone();
context.to_string()
}
pub async fn run(
state: &AppState,
resource: &Resource,
event: HookEvent,
request: &HookRequest,
payload: Value,
) -> Result<Option<Value>, HttpResponse> {
let Some(name) = resource.hook(event) else {
return Ok(None);
};
let context = context_json(resource, event, request, &payload);
invoke(
state,
resource,
event.as_str(),
name,
request,
context,
payload,
)
.await
}
pub async fn announce(
state: &AppState,
resource: &Resource,
event: HookEvent,
request: &HookRequest,
row: &Value,
) {
let Some(topic) = resource.publish.get(event) else {
return;
};
let published_by = request.principal_id.clone().unwrap_or_default();
if let Err(error) = state.queue.publish(topic, row, &published_by).await {
tracing::error!(
resource = %resource.meta.name,
event = event.as_str(),
topic,
%error,
"could not publish the message this write declares — the write itself succeeded"
);
}
}
pub async fn run_auth(
state: &AppState,
resource: &Resource,
event: AuthEvent,
request: &HookRequest,
payload: Value,
) -> Result<Option<Value>, HttpResponse> {
let Some(user) = state.app.resources.get("user") else {
return Ok(None);
};
let Some(name) = user.auth_hook(event) else {
return Ok(None);
};
let context = auth_context_json(resource, event, request, &payload);
invoke(
state,
resource,
event.as_str(),
name,
request,
context,
payload,
)
.await
}
async fn invoke(
state: &AppState,
resource: &Resource,
event: &str,
name: &str,
request: &HookRequest,
context: String,
payload: Value,
) -> Result<Option<Value>, HttpResponse> {
if state.functions.get(name).is_none() {
tracing::error!(
resource = %resource.meta.name,
hook = event,
function = name,
"hook function is not loaded"
);
return Err(error(
500,
format!(
"`{}` declares a `{event}` hook on a function `{name}` that is not loaded",
resource.meta.name,
),
));
}
let input = payload.to_string();
let principal_id = request.principal_id.clone().unwrap_or_default();
let functions = state.functions.clone();
let db = state.db.clone();
let mailer = state.mailer.clone();
let cache = state.cache.clone();
let payments = state.payments.clone();
let ai = state.ai.clone();
let queue = state.queue.clone();
let handle = tokio::runtime::Handle::current();
let name = name.to_string();
let hook_name = name.clone();
let result = tokio::task::spawn_blocking(move || {
let f = functions.get(&name).expect("checked above");
let bridge = HostBridge::new(db, handle, f.config_json.clone(), principal_id)
.with_services(mailer, cache, payments, ai)
.with_queue(queue)
.with_hook(context);
f.invoke(bridge, &input)
})
.await;
match result {
Ok(Ok(raw)) => outcome(&raw, &hook_name),
Ok(Err(message)) => match message.strip_prefix(apiplant_abi::INTERNAL_ERROR_PREFIX) {
Some(detail) => {
crate::telemetry::record_error("hook_fault", detail);
tracing::error!(hook = %hook_name, detail, "hook faulted");
Err(error(500, "hook failed"))
}
None => Err(error(400, message)),
},
Err(_) => {
crate::telemetry::record_error("hook_panic", &hook_name);
tracing::error!(hook = %hook_name, "hook task panicked");
Err(error(500, "hook failed"))
}
}
}
fn outcome(raw: &str, hook_name: &str) -> Result<Option<Value>, HttpResponse> {
let value: Value = match serde_json::from_str(raw) {
Ok(v) => v,
Err(e) => {
tracing::error!(hook = %hook_name, error = %e, "hook returned invalid JSON");
return Err(error(
500,
format!("hook `{hook_name}` returned invalid JSON"),
));
}
};
let Some(object) = value.as_object() else {
return Ok(None);
};
if let Some(rejection) = object.get("error") {
let (status, message) = match rejection {
Value::String(message) => (400, message.clone()),
Value::Object(details) => (
details
.get("status")
.and_then(Value::as_u64)
.and_then(|s| u16::try_from(s).ok())
.filter(|s| (400..=599).contains(s))
.unwrap_or(400),
details
.get("message")
.and_then(Value::as_str)
.unwrap_or("rejected by hook")
.to_string(),
),
other => (400, other.to_string()),
};
return Err(error(status, message));
}
Ok(object.get("data").cloned())
}
pub fn replacement_object(
replacement: Value,
hook_name: &str,
) -> Result<serde_json::Map<String, Value>, HttpResponse> {
match replacement {
Value::Object(map) => Ok(map),
_ => Err(error(
500,
format!("hook `{hook_name}` replaced the body with a non-object value"),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_resource(src: &str) -> Resource {
let resource: Resource = toml::from_str(src).unwrap();
resource.validate().unwrap();
resource
}
fn request() -> HookRequest {
HookRequest {
url: "/api/post?draft=true".into(),
method: "POST".into(),
query: HashMap::from([("draft".to_string(), "true".to_string())]),
authenticated: true,
principal_id: Some("11111111-1111-1111-1111-111111111111".into()),
organization_id: Some("22222222-2222-2222-2222-222222222222".into()),
role: Some("support".into()),
roles: vec!["support".into(), "billing".into()],
record_id: None,
}
}
#[test]
fn context_describes_the_event_and_the_caller() {
let resource = parse_resource("[resource]\nname = \"post\"\n");
let raw = context_json(
&resource,
HookEvent::BeforeCreate,
&request(),
&json!({ "title": "Draft" }),
);
let context: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(context["event"], "before_create");
assert_eq!(context["action"], "create");
assert_eq!(context["phase"], "before");
assert_eq!(context["resource"], "post");
assert_eq!(context["url"], "/api/post?draft=true");
assert_eq!(context["method"], "POST");
assert_eq!(context["query"]["draft"], "true");
assert_eq!(context["authenticated"], true);
assert_eq!(context["role"], "support");
assert_eq!(context["roles"][0], "support");
assert_eq!(context["roles"][1], "billing");
assert!(context["record_id"].is_null());
}
#[test]
fn payload_lands_in_the_slot_the_event_implies() {
let resource = parse_resource("[resource]\nname = \"post\"\n");
let row = json!({ "id": "abc", "title": "Hi" });
let created: Value = serde_json::from_str(&context_json(
&resource,
HookEvent::AfterCreate,
&request(),
&row,
))
.unwrap();
assert_eq!(created["row"], row);
assert!(created["data"].is_null());
assert!(created["rows"].is_null());
let listed: Value = serde_json::from_str(&context_json(
&resource,
HookEvent::AfterList,
&request(),
&json!([row]),
))
.unwrap();
assert_eq!(listed["rows"].as_array().unwrap().len(), 1);
assert!(listed["row"].is_null());
let submitted: Value = serde_json::from_str(&context_json(
&resource,
HookEvent::BeforeUpdate,
&request(),
&json!({ "title": "Edited" }),
))
.unwrap();
assert_eq!(submitted["data"]["title"], "Edited");
assert!(submitted["row"].is_null());
}
#[test]
fn record_id_is_carried_for_single_record_operations() {
let resource = parse_resource("[resource]\nname = \"post\"\n");
let id = Uuid::new_v4();
let raw = context_json(
&resource,
HookEvent::BeforeDelete,
&request().with_record(id),
&json!({}),
);
let context: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(context["record_id"], id.to_string());
}
#[test]
fn outcome_continues_on_empty_or_unrecognised_replies() {
assert!(outcome("{}", "h").unwrap().is_none());
assert!(outcome("null", "h").unwrap().is_none());
assert!(outcome("\"ok\"", "h").unwrap().is_none());
assert!(outcome(r#"{"logged":true}"#, "h").unwrap().is_none());
}
#[test]
fn outcome_extracts_replacement_data() {
let replacement = outcome(r#"{"data":{"title":"clean"}}"#, "h")
.unwrap()
.unwrap();
assert_eq!(replacement["title"], "clean");
let rows = outcome(r#"{"data":[{"id":"a"}]}"#, "h").unwrap().unwrap();
assert_eq!(rows.as_array().unwrap().len(), 1);
}
#[test]
fn outcome_maps_rejections_to_http_statuses() {
let err = outcome(
r#"{"error":{"status":422,"message":"title required"}}"#,
"h",
)
.unwrap_err();
assert_eq!(err.status().as_u16(), 422);
let plain = outcome(r#"{"error":"nope"}"#, "h").unwrap_err();
assert_eq!(plain.status().as_u16(), 400);
let odd = outcome(r#"{"error":{"status":200,"message":"x"}}"#, "h").unwrap_err();
assert_eq!(odd.status().as_u16(), 400);
let bare = outcome(r#"{"error":{}}"#, "h").unwrap_err();
assert_eq!(bare.status().as_u16(), 400);
}
#[test]
fn outcome_rejects_malformed_json_with_a_500() {
let err = outcome("{not json", "h").unwrap_err();
assert_eq!(err.status().as_u16(), 500);
}
#[test]
fn replacement_must_be_an_object_for_writes() {
let map = replacement_object(json!({ "title": "x" }), "h").unwrap();
assert_eq!(map["title"], "x");
let err = replacement_object(json!([1, 2]), "h").unwrap_err();
assert_eq!(err.status().as_u16(), 500);
}
}