Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 120 additions & 2 deletions crates/rite-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
/// Optional `{{field}}` template for the request body.
#[serde(default)]
body_template: Option<String>,
},
}

/// Render a deterministic HTTP action template against a normalized event.
///
/// Supported fields are `source`, `event_type`, `action`, `timestamp`,
/// `severity`, `title`, `body`, and `metadata.<key>[.<nested-key>...]`.
/// 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<String, Value>,
) -> 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 {
Expand Down Expand Up @@ -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()
));
}
}
23 changes: 21 additions & 2 deletions crates/rite-server/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions crates/rite-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions rite.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}\"}" }
Loading