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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ services:
| `WEBHOOK_URL` | (required) | URL to send webhook requests to |
| `WEBHOOK_METHOD` | `POST` | HTTP method for webhook requests |
| `INCLUDE_CONTENT` | `false` | Include full XML file content in payload |
| `OVERWRITE_WITH_RESPONSE` | `false` | Overwrite file with server response (requires `INCLUDE_CONTENT=true`) |
| `RUST_LOG` | - | Set log level (trace, debug, info, warn, error) |

## Webhook Payload
Expand Down Expand Up @@ -86,6 +87,21 @@ With `INCLUDE_CONTENT=true`:
}
```

## File Overwrite Feature

When `OVERWRITE_WITH_RESPONSE=true` is set (along with `INCLUDE_CONTENT=true`), the watcher will overwrite the original XML file with the response from the webhook server. This feature has the following requirements:

- The webhook must respond with a successful HTTP status code (2xx)
- The response `Content-Type` header must start with `text/xml` or `application/xml`
- The response body must not be empty

When these conditions are met, the watcher will:
1. Overwrite the original file with the response content
2. Temporarily ignore watch events for that file to prevent triggering a new webhook
3. Log the overwrite operation

This is useful for scenarios where the server processes the XML and returns a modified or transformed version.

## Development

### Run locally (without Docker)
Expand Down
98 changes: 94 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
use chrono::Utc;
use log::{error, info};
use log::{error, info, warn};
use notify::{Event, RecursiveMode, Result as NotifyResult, Watcher};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::env;
use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use std::sync::{mpsc::channel, Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;

// Duration to keep files in the ignore list after overwriting them
// This prevents triggering new webhook events when we modify the file
const IGNORE_DURATION_SECS: u64 = 2;

#[derive(Debug, Serialize, Deserialize)]
struct WebhookPayload {
event: String,
Expand All @@ -25,6 +30,7 @@ struct Config {
webhook_url: String,
webhook_method: String,
include_content: bool,
overwrite_with_response: bool,
}

impl Config {
Expand All @@ -43,11 +49,16 @@ impl Config {
.unwrap_or_else(|_| "false".to_string())
.to_lowercase() == "true";

let overwrite_with_response = env::var("OVERWRITE_WITH_RESPONSE")
.unwrap_or_else(|_| "false".to_string())
.to_lowercase() == "true";

Ok(Config {
watch_dir,
webhook_url,
webhook_method,
include_content,
overwrite_with_response,
})
}
}
Expand All @@ -59,7 +70,7 @@ fn is_xml_file(path: &Path) -> bool {
.unwrap_or(false)
}

async fn trigger_webhook(config: &Config, filepath: PathBuf) {
async fn trigger_webhook(config: &Config, filepath: PathBuf, ignore_list: Arc<Mutex<HashSet<PathBuf>>>) {
let filename = filepath
.file_name()
.and_then(|f| f.to_str())
Expand Down Expand Up @@ -109,6 +120,64 @@ async fn trigger_webhook(config: &Config, filepath: PathBuf) {
let status = response.status();
if status.is_success() {
info!(" Webhook sent successfully (HTTP {})", status.as_u16());

// Handle overwriting the file with response if enabled
let should_overwrite_with_response = |config: &Config| {
config.overwrite_with_response && config.include_content
};

if should_overwrite_with_response(&config) {
let content_type = response.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");

// Check if content type is appropriate (text/xml or application/xml)
// Accept content types that start with these prefixes (may include charset parameter)
let is_xml = content_type.starts_with("text/xml")
|| content_type.starts_with("application/xml");

if is_xml {
match response.text().await {
Ok(response_body) => {
if !response_body.is_empty() {
// Add file to ignore list before writing
{
let mut ignore = ignore_list.lock().unwrap();
ignore.insert(filepath.clone());
}

match tokio::fs::write(&filepath, &response_body).await {
Ok(_) => {
info!(" File overwritten with response content");
// Keep file in ignore list for a short time
let ignore_list_clone = Arc::clone(&ignore_list);
let filepath_clone = filepath.clone();
tokio::spawn(async move {
sleep(Duration::from_secs(IGNORE_DURATION_SECS)).await;
let mut ignore = ignore_list_clone.lock().unwrap();
ignore.remove(&filepath_clone);
});
}
Err(e) => {
error!(" Failed to overwrite file: {}", e);
// Remove from ignore list on failure
let mut ignore = ignore_list.lock().unwrap();
ignore.remove(&filepath);
}
}
} else {
warn!(" Response body is empty, not overwriting file");
}
}
Err(e) => {
error!(" Failed to read response body: {}", e);
}
}
} else {
warn!(" Response content-type '{}' is not XML, not overwriting file", content_type);
}
}
} else {
let body = response.text().await.unwrap_or_default();
error!(" Webhook failed (HTTP {}): {}", status.as_u16(), body);
Expand Down Expand Up @@ -137,11 +206,20 @@ async fn main() {
std::process::exit(1);
}

// Warn if overwrite is enabled without content inclusion
if config.overwrite_with_response && !config.include_content {
warn!("OVERWRITE_WITH_RESPONSE is enabled but INCLUDE_CONTENT is disabled. File overwrite will not work without including content in the webhook.");
}

info!("Starting XML file watcher...");
info!(" Watch directory: {}", config.watch_dir.display());
info!(" Webhook URL: {}", config.webhook_url);
info!(" Webhook method: {}", config.webhook_method);
info!(" Include content: {}", config.include_content);
info!(" Overwrite with response: {}", config.overwrite_with_response);

// Create an ignore list for files we've just modified
let ignore_list: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));

let (tx, rx) = channel();

Expand Down Expand Up @@ -169,11 +247,23 @@ async fn main() {
if matches!(event.kind, notify::EventKind::Create(_)) {
for path in event.paths {
if path.is_file() && is_xml_file(&path) {
// Check if this file is in the ignore list
let should_ignore = {
let ignore = ignore_list.lock().unwrap();
ignore.contains(&path)
};

if should_ignore {
info!("Ignoring file event for recently modified file: {}", path.display());
continue;
}

// Small delay to ensure file is fully written
let config_clone = config.clone();
let ignore_list_clone = Arc::clone(&ignore_list);
tokio::spawn(async move {
sleep(Duration::from_millis(500)).await;
trigger_webhook(&config_clone, path).await;
trigger_webhook(&config_clone, path, ignore_list_clone).await;
});
}
}
Expand Down