diff --git a/README.md b/README.md index 65646b8..a3761ce 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,25 @@ ingress. Omit `RITE_IRIS_BASE_URL` when you do not want an Iris subscription. For a complete private-network example with both services, use the Iris repository's [`deploy/docker-compose.yml`](https://github.com/TechGodHQ/iris/blob/main/deploy/docker-compose.yml). +## HTTP actions + +`http_post` forwards the normalized event as JSON by default. A handler may +instead set `body_template` to render a deterministic text body using +`{{event_type}}`, `{{action}}`, `{{title}}`, `{{body}}`, or nested event +metadata such as `{{metadata.provider}}`; missing fields render empty. +Optional `headers` are forwarded verbatim. Templated bodies default to +`Content-Type: text/plain` unless that header is explicitly set. This remains +a generic HTTP action: Discord, Slack, and other webhook targets do not add +provider-specific Rite action types. + +```toml +[[rites]] +name = "deploy-alert" +source = "iris" +match = { body_contains = "URGENT" } +action = { type = "http_post", url = "https://hooks.example.test/alerts", headers = { "content-type" = "application/json" }, body_template = "{\"content\":\"{{title}}: {{body}}\"}" } +``` + ## Development ```bash diff --git a/crates/rite-core/src/lib.rs b/crates/rite-core/src/lib.rs index e5247f4..a5badac 100644 --- a/crates/rite-core/src/lib.rs +++ b/crates/rite-core/src/lib.rs @@ -129,8 +129,82 @@ impl MatchValue { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum RiteAction { - /// Forward the normalized event as JSON to an HTTP endpoint. - HttpPost { url: Url }, + /// Forward the normalized event as JSON, or a configured template, to an HTTP endpoint. + HttpPost { + /// Destination URL. + url: Url, + /// Additional request headers. Existing configurations omit this field. + #[serde(default)] + headers: BTreeMap, + /// Optional `{{field}}` template for the request body. + #[serde(default)] + body_template: Option, + }, +} + +/// Render a deterministic HTTP action template against a normalized event. +/// +/// Supported fields are `source`, `event_type`, `action`, `timestamp`, +/// `severity`, `title`, `body`, and `metadata.[....]`. +/// Missing fields intentionally render as an empty string so a handler cannot +/// fail merely because an optional source field was absent. +#[must_use] +pub fn render_template(template: &str, event: &RiteEvent) -> String { + let mut rendered = String::with_capacity(template.len()); + let mut remaining = template; + while let Some(open) = remaining.find("{{") { + rendered.push_str(&remaining[..open]); + let field_start = open + 2; + let Some(close_offset) = remaining[field_start..].find("}}") else { + rendered.push_str(&remaining[open..]); + return rendered; + }; + let field_end = field_start + close_offset; + rendered.push_str(&template_value(&remaining[field_start..field_end], event)); + remaining = &remaining[field_end + 2..]; + } + rendered.push_str(remaining); + rendered +} + +fn template_value(field: &str, event: &RiteEvent) -> String { + match field { + "source" => event.source.clone(), + "event_type" => event.event_type.clone(), + "action" => event.action.clone().unwrap_or_default(), + "timestamp" => event.timestamp.to_rfc3339(), + "severity" => serde_json::to_value(event.severity) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_default(), + "title" => event.title.clone(), + "body" => event.body.clone().unwrap_or_default(), + field => field + .strip_prefix("metadata.") + .and_then(|path| template_metadata_value(path, &event.metadata)) + .map(json_value_text) + .unwrap_or_default(), + } +} + +fn template_metadata_value<'a>( + path: &str, + metadata: &'a BTreeMap, +) -> Option<&'a Value> { + let mut segments = path.split('.'); + let mut value = metadata.get(segments.next()?)?; + for segment in segments { + value = value.get(segment)?; + } + Some(value) +} + +fn json_value_text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Null => String::new(), + other => other.to_string(), + } } impl RiteHandler { @@ -213,4 +287,48 @@ action = { type = "http_post", url = "https://example.test/hook" }"#, }; assert!(handler.matches(&event)); } + + #[test] + fn templates_render_event_and_nested_metadata_fields() { + let event = RiteEvent { + source: "iris".into(), + event_type: "text".into(), + action: None, + timestamp: Utc::now(), + severity: Severity::Warning, + title: "Attention".into(), + body: Some("Deploy paused".into()), + metadata: BTreeMap::from([( + "provider".into(), + serde_json::json!({ "name": "telegram", "id": 42 }), + )]), + }; + + assert_eq!( + render_template( + "{{event_type}}/{{action}} {{title}}: {{body}} {{metadata.provider.name}} #{{metadata.provider.id}} {{metadata.missing}}", + &event, + ), + "text/ Attention: Deploy paused telegram #42 " + ); + } + + #[test] + fn http_post_defaults_preserve_existing_toml() { + let handler: RiteHandler = toml::from_str( + r#"name = "legacy" +source = "github" +action = { type = "http_post", url = "https://example.test/hook" }"#, + ) + .expect("legacy handler parses"); + + assert!(matches!( + handler.action, + RiteAction::HttpPost { + headers, + body_template: None, + .. + } if headers.is_empty() + )); + } } diff --git a/crates/rite-server/src/dispatch.rs b/crates/rite-server/src/dispatch.rs index e0ec17e..8fd6c99 100644 --- a/crates/rite-server/src/dispatch.rs +++ b/crates/rite-server/src/dispatch.rs @@ -184,8 +184,27 @@ async fn receive_event(state: &AppState, input: RawOperationInput) -> axum::resp for handler in &matched { tracing::info!(handler = %handler.name, "Webhook event matched handler"); match &handler.action { - RiteAction::HttpPost { url } => { - match state.client.post(url.clone()).json(&event).send().await { + RiteAction::HttpPost { + url, + headers, + body_template, + } => { + let mut request = state.client.post(url.clone()); + for (name, value) in headers { + request = request.header(name, value); + } + if let Some(template) = body_template { + if !headers + .keys() + .any(|name| name.eq_ignore_ascii_case("content-type")) + { + request = request.header("content-type", "text/plain"); + } + request = request.body(rite_core::render_template(template, &event)); + } else { + request = request.json(&event); + } + match request.send().await { Ok(response) if response.status().is_success() => { executed += 1; state diff --git a/crates/rite-server/src/lib.rs b/crates/rite-server/src/lib.rs index 8ca4e4a..1f2587a 100644 --- a/crates/rite-server/src/lib.rs +++ b/crates/rite-server/src/lib.rs @@ -229,8 +229,27 @@ pub fn start_iris_subscription(state: &AppState) { } for handler in matched { tracing::info!(handler = %handler.name, "Iris event matched handler"); - let RiteAction::HttpPost { url } = &handler.action; - match client.post(url.clone()).json(&event).send().await { + let RiteAction::HttpPost { + url, + headers, + body_template, + } = &handler.action; + let mut request = client.post(url.clone()); + for (name, value) in headers { + request = request.header(name, value); + } + if let Some(template) = body_template { + if !headers + .keys() + .any(|name| name.eq_ignore_ascii_case("content-type")) + { + request = request.header("content-type", "text/plain"); + } + request = request.body(rite_core::render_template(template, &event)); + } else { + request = request.json(&event); + } + match request.send().await { Ok(response) if response.status().is_success() => { metrics.actions_succeeded.fetch_add(1, Ordering::Relaxed); tracing::info!(handler = %handler.name, "Iris event action completed"); diff --git a/rite.example.toml b/rite.example.toml index 7b7b956..da9ee57 100644 --- a/rite.example.toml +++ b/rite.example.toml @@ -9,7 +9,7 @@ enabled = true base_url = "http://127.0.0.1:3000" [[rites]] -name = "urgent-telegram" +name = "discord-urgent-alert" source = "iris" match = { provider = "telegram", body_contains = "URGENT" } -action = { type = "http_post", url = "https://example.test/hooks/urgent" } +action = { type = "http_post", url = "https://example.test/hooks/urgent", headers = { "content-type" = "application/json" }, body_template = "{\"content\":\"{{title}}: {{body}}\"}" }