synops/maskinrommet/src/webhook.rs
vegard a3dfa3b254 Webhook-templates: forhåndsdefinerte mappinger for kjente tjenester (oppgave 29.6)
Legger til et template-system for webhooks som vet hvordan kjente
tjenester strukturerer sine JSON-payloads, og mapper dem til
meningsfulle node title/content/metadata.

Templates:
- github-push: Commits med repo, branch, pusher, formaterte meldinger
- github-issues: Issue-hendelser med nummer, labels, state
- github-pull-request: PR-hendelser med branch-info, merge-status
- slack-message: Slack Event API-meldinger med kanal og bruker
- ci-build: Generisk CI/CD (GitHub Actions, GitLab CI, Jenkins)

Backend:
- webhook_templates.rs: Template-definisjoner og apply-logikk
- webhook.rs: Bruker template fra webhook-nodens metadata.template_id
- webhook_admin.rs: GET /admin/webhooks/templates, POST set_template,
  template_id i create og list

Frontend:
- Template-velger i opprett-skjema og på hver webhook-kort
- Kan bytte template på eksisterende webhooks

6 unit-tester for alle templates. Verifisert med curl mot live endpoint.
2026-03-18 22:10:33 +00:00

187 lines
5.6 KiB
Rust

// Webhook-endepunkt: POST /api/webhook/<token>
//
// Offentlig endepunkt (ingen JWT). Validerer token mot en webhook-node
// i databasen, oppretter content-node med payload i metadata, og kobler
// den til målsamlingen via belongs_to-edge.
//
// Ref: docs/features/universell_input.md (Webhook-seksjon)
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::Serialize;
use uuid::Uuid;
use crate::webhook_templates;
use crate::AppState;
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Serialize)]
pub struct WebhookResponse {
pub node_id: Uuid,
pub edge_id: Uuid,
}
fn not_found(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse { error: msg.to_string() }),
)
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: msg.to_string() }),
)
}
/// Rad fra token-oppslag: webhook-nodens id, målsamling og evt. template.
#[derive(sqlx::FromRow)]
struct WebhookLookup {
webhook_id: Uuid,
collection_id: Uuid,
template_id: Option<String>,
}
/// POST /api/webhook/<token>
///
/// Mottar vilkårlig JSON-body og oppretter en content-node med payload
/// i metadata. Noden knyttes til webhook-nodens målsamling via belongs_to-edge.
pub async fn handle_webhook(
State(state): State<AppState>,
Path(token): Path<Uuid>,
body: Option<Json<serde_json::Value>>,
) -> Result<Json<WebhookResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Slå opp webhook-node via token --
// Token lagres i metadata.token på en webhook-node.
// Målsamlingen finnes via belongs_to-edge fra webhook-noden.
let lookup = sqlx::query_as::<_, WebhookLookup>(
r#"SELECT w.id AS webhook_id, e.target_id AS collection_id,
w.metadata->>'template_id' AS template_id
FROM nodes w
JOIN edges e ON e.source_id = w.id AND e.edge_type = 'belongs_to'
WHERE w.node_kind = 'webhook'
AND w.metadata->>'token' = $1::text"#,
)
.bind(token.to_string())
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved webhook-token-oppslag: {e}");
internal_error("Databasefeil")
})?;
let lookup = match lookup {
Some(l) => l,
None => return Err(not_found("Ugyldig webhook-token")),
};
// -- Payload --
let payload = body
.map(|Json(v)| v)
.unwrap_or(serde_json::json!({}));
// -- Ekstraher title/content via template eller generisk --
let (title, content, extra_meta) = if let Some(ref tmpl_id) = lookup.template_id {
if let Some(result) = webhook_templates::apply_template(tmpl_id, &payload) {
(result.title, result.content, Some(result.extra_metadata))
} else {
tracing::warn!(template_id = %tmpl_id, "Ukjent webhook-template, bruker generisk");
extract_generic(&payload)
}
} else {
extract_generic(&payload)
};
// -- Opprett content-node --
let node_id = Uuid::now_v7();
let mut metadata = serde_json::json!({
"source": "webhook",
"webhook_id": lookup.webhook_id.to_string(),
"payload": payload,
});
// Merg inn ekstra metadata fra template
if let Some(extra) = extra_meta {
if let Some(obj) = extra.as_object() {
for (k, v) in obj {
metadata[k.clone()] = v.clone();
}
}
}
sqlx::query(
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'content', NULLIF($2, ''), NULLIF($3, ''), 'hidden'::visibility, $4, $5)"#,
)
.bind(node_id)
.bind(&title)
.bind(&content)
.bind(&metadata)
.bind(lookup.webhook_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av webhook-node: {e}");
internal_error("Databasefeil ved opprettelse av node")
})?;
// -- belongs_to-edge til målsamling --
let edge_id = Uuid::now_v7();
sqlx::query(
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, true, $4)"#,
)
.bind(edge_id)
.bind(node_id)
.bind(lookup.collection_id)
.bind(lookup.webhook_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("PG-feil ved opprettelse av belongs_to-edge: {e}");
internal_error("Databasefeil ved opprettelse av edge")
})?;
// -- Propager tilgang fra samlingen til den nye noden --
if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
.bind(node_id)
.bind(lookup.collection_id)
.execute(&state.db)
.await
{
tracing::error!("propagate_belongs_to_access feilet for webhook-node: {e}");
}
tracing::info!(
node_id = %node_id,
edge_id = %edge_id,
webhook_id = %lookup.webhook_id,
collection_id = %lookup.collection_id,
"Webhook mottatt — content-node opprettet"
);
Ok(Json(WebhookResponse { node_id, edge_id }))
}
/// Generisk ekstraksjon: prøv title og content/body-felt.
fn extract_generic(payload: &serde_json::Value) -> (String, String, Option<serde_json::Value>) {
let title = payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let content = payload
.get("content")
.or_else(|| payload.get("body"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(title, content, None)
}