initial commit

This commit is contained in:
Timur Gordon
2025-07-29 01:15:23 +02:00
commit 7d7ff0f0ab
108 changed files with 24713 additions and 0 deletions

25
proxies/http/Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[package]
name = "hero-http-proxy"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.4"
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
env_logger = "0.10"
clap = { version = "4.0", features = ["derive"] }
bytes = "1.5"
reqwest = { version = "0.11", features = ["json"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
futures-util = "0.3"
thiserror = "1.0"
url = "2.4"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
anyhow = "1.0"
uuid = { version = "1.0", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }

149
proxies/http/README.md Normal file
View File

@@ -0,0 +1,149 @@
# Hero HTTP Proxy
HTTP proxy server for converting webhook requests to WebSocket JSON-RPC calls to the Hero WebSocket server.
## Overview
This proxy server acts as a bridge between HTTP webhook endpoints (like Stripe, iDenfy) and the Hero WebSocket server. It receives HTTP webhook requests, verifies signatures, and forwards them as JSON-RPC calls to the WebSocket server.
## Features
- **Webhook Support**: Built-in support for Stripe and iDenfy webhooks
- **Signature Verification**: HMAC-SHA256 signature verification for security
- **Extensible Design**: Easy to add new webhook providers
- **WebSocket Connection Pooling**: Reuses WebSocket connections for efficiency
- **Configurable**: JSON-based configuration with environment variable support
- **Health Checks**: Built-in health check endpoint
## Configuration
The proxy can be configured via a JSON configuration file or environment variables:
### Environment Variables
- `STRIPE_WEBHOOK_SECRET`: Stripe webhook signing secret
- `IDENFY_WEBHOOK_SECRET`: iDenfy webhook signing secret
### Configuration File Example
```json
{
"webhooks": {
"stripe": {
"secret": "whsec_...",
"signature_header": "stripe-signature",
"verify_signature": true
},
"idenfy": {
"secret": "your_idenfy_secret",
"signature_header": "idenfy-signature",
"verify_signature": true
}
},
"websocket_timeout": 30,
"max_retries": 3
}
```
## Usage
### Basic Usage
```bash
cargo run -- --port 8080 --websocket-url ws://localhost:3030
```
### With Configuration File
```bash
cargo run -- --port 8080 --websocket-url ws://localhost:3030 --config config.json
```
### Command Line Options
- `--port, -p`: HTTP server port (default: 8080)
- `--websocket-url, -w`: WebSocket server URL (default: ws://localhost:3030)
- `--config, -c`: Configuration file path (optional)
## Endpoints
### Webhook Endpoints
- `POST /webhooks/stripe/{circle_pk}`: Stripe webhook handler
- `POST /webhooks/idenfy/{circle_pk}`: iDenfy webhook handler
### Health Check
- `GET /health`: Health check endpoint
## Adding New Webhook Providers
To add a new webhook provider:
1. **Add configuration** in `src/config.rs`:
```rust
webhooks.insert("newprovider".to_string(), WebhookConfig {
secret: std::env::var("NEWPROVIDER_WEBHOOK_SECRET").unwrap_or_default(),
signature_header: "newprovider-signature".to_string(),
verify_signature: true,
});
```
2. **Add signature verification** in `src/webhook/signature.rs`:
```rust
pub fn verify_newprovider_signature(
payload: &[u8],
signature_header: &str,
secret: &str,
) -> Result<(), ProxyError> {
// Implementation specific to the provider
}
```
3. **Add handler** in `src/webhook/handlers.rs`:
```rust
pub async fn handle_newprovider_webhook(
req: HttpRequest,
path: web::Path<String>,
body: Bytes,
data: web::Data<Arc<ProxyState>>,
) -> ActixResult<HttpResponse> {
// Handler implementation
}
```
4. **Register route** in `src/main.rs`:
```rust
.route("/newprovider/{circle_pk}", web::post().to(webhook::handlers::handle_newprovider_webhook))
```
## Architecture
```
HTTP Webhook → Signature Verification → JSON-RPC → WebSocket Server
↓ ↓ ↓ ↓
Stripe/iDenfy HMAC-SHA256 Check play method Hero Server
```
The proxy maintains persistent WebSocket connections to the Hero server and forwards webhook events as `play` method calls with appropriate scripts (e.g., `stripe_webhook_received`, `idenfy_webhook_received`).
## Dependencies
- **actix-web**: HTTP server framework
- **tokio-tungstenite**: WebSocket client
- **heromodels**: Hero project models (local dependency)
- **serde**: JSON serialization
- **hmac/sha2**: Signature verification
- **clap**: Command line argument parsing
## Development
```bash
# Build
cargo build
# Run tests
cargo test
# Run with debug logging
RUST_LOG=debug cargo run
# Format code
cargo fmt
# Check for issues
cargo clippy
```

View File

@@ -0,0 +1,16 @@
{
"webhooks": {
"stripe": {
"secret": "whsec_test_secret_replace_with_actual",
"signature_header": "stripe-signature",
"verify_signature": true
},
"idenfy": {
"secret": "idenfy_test_secret_replace_with_actual",
"signature_header": "idenfy-signature",
"verify_signature": true
}
},
"websocket_timeout": 30,
"max_retries": 3
}

View File

@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use anyhow::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Webhook configurations for different providers
pub webhooks: HashMap<String, WebhookConfig>,
/// Default timeout for WebSocket requests (in seconds)
pub websocket_timeout: u64,
/// Maximum retries for WebSocket connections
pub max_retries: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
/// Secret key for signature verification
pub secret: String,
/// Signature header name
pub signature_header: String,
/// Whether signature verification is enabled
pub verify_signature: bool,
}
impl Default for Config {
fn default() -> Self {
let mut webhooks = HashMap::new();
// Default Stripe configuration
webhooks.insert("stripe".to_string(), WebhookConfig {
secret: std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(),
signature_header: "stripe-signature".to_string(),
verify_signature: true,
});
// Default iDenfy configuration
webhooks.insert("idenfy".to_string(), WebhookConfig {
secret: std::env::var("IDENFY_WEBHOOK_SECRET").unwrap_or_default(),
signature_header: "idenfy-signature".to_string(),
verify_signature: true,
});
Self {
webhooks,
websocket_timeout: 30,
max_retries: 3,
}
}
}
impl Config {
pub fn from_file(path: &str) -> Result<Self> {
let content = fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&content)?;
Ok(config)
}
pub fn get_webhook_config(&self, provider: &str) -> Option<&WebhookConfig> {
self.webhooks.get(provider)
}
}

63
proxies/http/src/error.rs Normal file
View File

@@ -0,0 +1,63 @@
use actix_web::{HttpResponse, ResponseError};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ProxyError {
#[error("WebSocket connection error: {0}")]
WebSocketError(String),
#[error("JSON-RPC error: {0}")]
JsonRpcError(String),
#[error("Webhook signature verification failed: {0}")]
SignatureVerificationError(String),
#[error("Invalid webhook payload: {0}")]
InvalidPayload(String),
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("Timeout error: {0}")]
TimeoutError(String),
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Network error: {0}")]
NetworkError(#[from] reqwest::Error),
#[error("WebSocket error: {0}")]
TungsteniteError(#[from] tokio_tungstenite::tungstenite::Error),
}
impl ResponseError for ProxyError {
fn error_response(&self) -> HttpResponse {
match self {
ProxyError::SignatureVerificationError(_) => {
HttpResponse::Unauthorized().json(serde_json::json!({
"error": "signature_verification_failed",
"message": self.to_string()
}))
}
ProxyError::InvalidPayload(_) => {
HttpResponse::BadRequest().json(serde_json::json!({
"error": "invalid_payload",
"message": self.to_string()
}))
}
ProxyError::TimeoutError(_) => {
HttpResponse::RequestTimeout().json(serde_json::json!({
"error": "timeout",
"message": self.to_string()
}))
}
_ => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "internal_server_error",
"message": self.to_string()
}))
}
}
}
}

67
proxies/http/src/main.rs Normal file
View File

@@ -0,0 +1,67 @@
use actix_web::{web, App, HttpServer, middleware::Logger};
use clap::Parser;
use log::info;
use std::sync::Arc;
mod config;
mod error;
mod proxy;
mod types;
mod webhook;
mod websocket;
use config::Config;
use proxy::ProxyState;
#[derive(Parser)]
#[command(name = "hero-http-proxy")]
#[command(about = "HTTP proxy server for converting webhooks to WebSocket JSON-RPC calls")]
struct Args {
/// HTTP server port
#[arg(short, long, default_value = "8080")]
port: u16,
/// WebSocket server URL
#[arg(short, long, default_value = "ws://localhost:3030")]
websocket_url: String,
/// Configuration file path
#[arg(short, long)]
config: Option<String>,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init();
let args = Args::parse();
// Load configuration
let config = if let Some(config_path) = args.config {
Config::from_file(&config_path).unwrap_or_else(|e| {
eprintln!("Failed to load config from {}: {}", config_path, e);
std::process::exit(1);
})
} else {
Config::default()
};
let proxy_state = Arc::new(ProxyState::new(args.websocket_url, config));
info!("Starting HTTP proxy server on port {}", args.port);
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(proxy_state.clone()))
.wrap(Logger::default())
.service(
web::scope("/webhooks")
.route("/stripe/{circle_pk}", web::post().to(webhook::handlers::handle_stripe_webhook))
.route("/idenfy/{circle_pk}", web::post().to(webhook::handlers::handle_idenfy_webhook))
)
.route("/health", web::get().to(webhook::handlers::health_check))
})
.bind(("0.0.0.0", args.port))?
.run()
.await
}

43
proxies/http/src/proxy.rs Normal file
View File

@@ -0,0 +1,43 @@
use crate::config::Config;
use crate::error::ProxyError;
use crate::websocket::WebSocketClient;
use std::sync::Arc;
use tokio::sync::Mutex;
/// Shared state for the HTTP proxy server
pub struct ProxyState {
/// WebSocket server URL
pub websocket_url: String,
/// Configuration
pub config: Config,
/// WebSocket client pool (for connection reuse)
pub ws_client: Arc<Mutex<Option<WebSocketClient>>>,
}
impl ProxyState {
pub fn new(websocket_url: String, config: Config) -> Self {
Self {
websocket_url,
config,
ws_client: Arc::new(Mutex::new(None)),
}
}
/// Get or create a WebSocket client connection
pub async fn get_ws_client(&self) -> Result<WebSocketClient, ProxyError> {
let mut client_guard = self.ws_client.lock().await;
// Check if we have a valid connection
if let Some(ref client) = *client_guard {
if client.is_connected().await {
return Ok(client.clone());
}
}
// Create new connection
let client = WebSocketClient::new(&self.websocket_url).await?;
*client_guard = Some(client.clone());
Ok(client)
}
}

48
proxies/http/src/types.rs Normal file
View File

@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Stripe webhook event structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StripeWebhookEvent {
/// The event ID
pub id: String,
/// The event type (e.g., "payment_intent.succeeded")
#[serde(rename = "type")]
pub event_type: String,
/// The event data
pub data: StripeEventData,
/// When the event was created
pub created: i64,
/// Whether this is a live mode event
pub livemode: bool,
/// API version
pub api_version: Option<String>,
/// Number of times delivery was attempted
pub pending_webhooks: Option<i32>,
/// The request that caused this event
pub request: Option<StripeEventRequest>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StripeEventData {
/// The object that triggered the event
pub object: Value,
/// Previous attributes (for update events)
pub previous_attributes: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StripeEventRequest {
/// The request ID
pub id: Option<String>,
/// The idempotency key
pub idempotency_key: Option<String>,
}
/// Generic webhook payload for providers that don't need specific parsing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenericWebhookPayload {
/// Raw JSON payload
#[serde(flatten)]
pub data: Value,
}

View File

@@ -0,0 +1,222 @@
use crate::error::ProxyError;
use crate::proxy::ProxyState;
use crate::types::StripeWebhookEvent;
use crate::webhook::signature::verify_webhook_signature;
use actix_web::{web, HttpRequest, HttpResponse, ResponseError, Result as ActixResult};
use bytes::Bytes;
use log::{debug, error, info};
use serde_json::Value;
use std::sync::Arc;
/// Extract signature from request headers
fn extract_signature_header(
headers: &actix_web::http::header::HeaderMap,
header_name: &str,
) -> Result<String, ProxyError> {
match headers.get(header_name) {
Some(sig) => match sig.to_str() {
Ok(s) => Ok(s.to_string()),
Err(_) => Err(ProxyError::SignatureVerificationError(format!(
"Invalid {} header format", header_name
))),
},
None => Err(ProxyError::SignatureVerificationError(format!(
"Missing {} header", header_name
))),
}
}
/// Handle Stripe webhook requests
pub async fn handle_stripe_webhook(
req: HttpRequest,
path: web::Path<String>,
body: Bytes,
data: web::Data<Arc<ProxyState>>,
) -> ActixResult<HttpResponse> {
let circle_pk = path.into_inner();
info!("Received Stripe webhook for circle: {}", circle_pk);
// Get Stripe webhook configuration
let webhook_config = match data.config.get_webhook_config("stripe") {
Some(config) => config,
None => {
error!("Stripe webhook configuration not found");
return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": "configuration_error",
"message": "Stripe webhook configuration not found"
})));
}
};
// Verify signature if enabled
if webhook_config.verify_signature {
let signature = match extract_signature_header(req.headers(), &webhook_config.signature_header) {
Ok(sig) => sig,
Err(e) => {
error!("Failed to extract Stripe signature: {}", e);
return Ok(e.error_response());
}
};
if let Err(e) = verify_webhook_signature("stripe", &body, &signature, &webhook_config.secret) {
error!("Stripe signature verification failed: {}", e);
return Ok(e.error_response());
}
}
// Parse the webhook payload
let webhook_event: StripeWebhookEvent = match serde_json::from_slice(&body) {
Ok(event) => event,
Err(e) => {
error!("Failed to parse Stripe webhook payload: {}", e);
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "invalid_payload",
"message": format!("Failed to parse webhook payload: {}", e)
})));
}
};
info!("Processing Stripe webhook event: {} for circle: {}", webhook_event.event_type, circle_pk);
// Forward to WebSocket server via JSON-RPC
match forward_stripe_webhook_to_ws(&data, &circle_pk, &webhook_event).await {
Ok(result) => {
info!("Successfully processed Stripe webhook for circle: {}", circle_pk);
Ok(HttpResponse::Ok().json(serde_json::json!({
"status": "success",
"message": "Webhook processed successfully",
"result": result
})))
}
Err(e) => {
error!("Failed to process Stripe webhook: {}", e);
Ok(e.error_response())
}
}
}
/// Forward Stripe webhook to WebSocket server
async fn forward_stripe_webhook_to_ws(
proxy_state: &ProxyState,
circle_id: &str,
event: &StripeWebhookEvent,
) -> Result<Value, ProxyError> {
let ws_client = proxy_state.get_ws_client().await?;
// Create the script to execute
let event_json = serde_json::to_string(event)
.map_err(|e| ProxyError::InvalidPayload(format!("Failed to serialize Stripe event: {}", e)))?;
let script = format!(
"stripe_webhook_received('{}', 'http_proxy', {})",
circle_id, event_json
);
debug!("Executing Stripe webhook script: {}", script);
// Execute via WebSocket JSON-RPC
ws_client.execute_play(circle_id, "http_proxy", &script).await
}
/// Handle iDenfy webhook requests
pub async fn handle_idenfy_webhook(
req: HttpRequest,
path: web::Path<String>,
body: Bytes,
data: web::Data<Arc<ProxyState>>,
) -> ActixResult<HttpResponse> {
let circle_pk = path.into_inner();
info!("Received iDenfy webhook for circle: {}", circle_pk);
// Get iDenfy webhook configuration
let webhook_config = match data.config.get_webhook_config("idenfy") {
Some(config) => config,
None => {
error!("iDenfy webhook configuration not found");
return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
"error": "configuration_error",
"message": "iDenfy webhook configuration not found"
})));
}
};
// Verify signature if enabled
if webhook_config.verify_signature {
let signature = match extract_signature_header(req.headers(), &webhook_config.signature_header) {
Ok(sig) => sig,
Err(e) => {
error!("Failed to extract iDenfy signature: {}", e);
return Ok(e.error_response());
}
};
if let Err(e) = verify_webhook_signature("idenfy", &body, &signature, &webhook_config.secret) {
error!("iDenfy signature verification failed: {}", e);
return Ok(e.error_response());
}
}
// Parse the webhook payload as generic JSON
let webhook_payload: Value = match serde_json::from_slice(&body) {
Ok(payload) => payload,
Err(e) => {
error!("Failed to parse iDenfy webhook payload: {}", e);
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
"error": "invalid_payload",
"message": format!("Failed to parse webhook payload: {}", e)
})));
}
};
info!("Processing iDenfy webhook for circle: {}", circle_pk);
// Forward to WebSocket server via JSON-RPC
match forward_idenfy_webhook_to_ws(&data, &circle_pk, &webhook_payload).await {
Ok(result) => {
info!("Successfully processed iDenfy webhook for circle: {}", circle_pk);
Ok(HttpResponse::Ok().json(serde_json::json!({
"status": "success",
"message": "Webhook processed successfully",
"result": result
})))
}
Err(e) => {
error!("Failed to process iDenfy webhook: {}", e);
Ok(e.error_response())
}
}
}
/// Forward iDenfy webhook to WebSocket server
async fn forward_idenfy_webhook_to_ws(
proxy_state: &ProxyState,
circle_id: &str,
payload: &Value,
) -> Result<Value, ProxyError> {
let ws_client = proxy_state.get_ws_client().await?;
// Create the script to execute
let payload_json = serde_json::to_string(payload)
.map_err(|e| ProxyError::InvalidPayload(format!("Failed to serialize iDenfy payload: {}", e)))?;
let script = format!(
"idenfy_webhook_received('{}', 'http_proxy', {})",
circle_id, payload_json
);
debug!("Executing iDenfy webhook script: {}", script);
// Execute via WebSocket JSON-RPC
ws_client.execute_play(circle_id, "http_proxy", &script).await
}
/// Health check endpoint
pub async fn health_check() -> ActixResult<HttpResponse> {
Ok(HttpResponse::Ok().json(serde_json::json!({
"status": "ok",
"service": "hero-http-proxy",
"timestamp": chrono::Utc::now().to_rfc3339()
})))
}

View File

@@ -0,0 +1,2 @@
pub mod handlers;
pub mod signature;

View File

@@ -0,0 +1,128 @@
use crate::error::ProxyError;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use hex;
type HmacSha256 = Hmac<Sha256>;
/// Verify Stripe webhook signature
pub fn verify_stripe_signature(
payload: &[u8],
signature_header: &str,
secret: &str,
) -> Result<(), ProxyError> {
// Parse the signature header (format: "t=timestamp,v1=signature")
let mut timestamp = None;
let mut signature = None;
for part in signature_header.split(',') {
if let Some((key, value)) = part.split_once('=') {
match key {
"t" => timestamp = Some(value),
"v1" => signature = Some(value),
_ => {} // Ignore unknown parts
}
}
}
let timestamp = timestamp.ok_or_else(|| {
ProxyError::SignatureVerificationError("Missing timestamp in signature header".to_string())
})?;
let expected_signature = signature.ok_or_else(|| {
ProxyError::SignatureVerificationError("Missing signature in signature header".to_string())
})?;
// Create the signed payload (timestamp.payload)
let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(payload));
// Calculate HMAC-SHA256
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|e| ProxyError::SignatureVerificationError(format!("Invalid secret: {}", e)))?;
mac.update(signed_payload.as_bytes());
let computed_signature = hex::encode(mac.finalize().into_bytes());
// Compare signatures
if computed_signature != expected_signature {
return Err(ProxyError::SignatureVerificationError(
"Signature verification failed".to_string()
));
}
Ok(())
}
/// Verify iDenfy webhook signature
pub fn verify_idenfy_signature(
payload: &[u8],
signature_header: &str,
secret: &str,
) -> Result<(), ProxyError> {
// iDenfy uses HMAC-SHA256 with the raw payload
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|e| ProxyError::SignatureVerificationError(format!("Invalid secret: {}", e)))?;
mac.update(payload);
let computed_signature = hex::encode(mac.finalize().into_bytes());
// Compare signatures (case-insensitive)
if computed_signature.to_lowercase() != signature_header.to_lowercase() {
return Err(ProxyError::SignatureVerificationError(
"Signature verification failed".to_string()
));
}
Ok(())
}
/// Generic signature verification function
pub fn verify_webhook_signature(
provider: &str,
payload: &[u8],
signature_header: &str,
secret: &str,
) -> Result<(), ProxyError> {
match provider {
"stripe" => verify_stripe_signature(payload, signature_header, secret),
"idenfy" => verify_idenfy_signature(payload, signature_header, secret),
_ => Err(ProxyError::SignatureVerificationError(
format!("Unsupported webhook provider: {}", provider)
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stripe_signature_verification() {
let payload = b"test payload";
let secret = "test_secret";
let timestamp = "1234567890";
// Create a valid signature
let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(payload));
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(signed_payload.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
let signature_header = format!("t={},v1={}", timestamp, signature);
assert!(verify_stripe_signature(payload, &signature_header, secret).is_ok());
}
#[test]
fn test_idenfy_signature_verification() {
let payload = b"test payload";
let secret = "test_secret";
// Create a valid signature
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(payload);
let signature = hex::encode(mac.finalize().into_bytes());
assert!(verify_idenfy_signature(payload, &signature, secret).is_ok());
}
}

View File

@@ -0,0 +1,192 @@
use crate::error::ProxyError;
use futures_util::{SinkExt, StreamExt};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use url::Url;
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
pub jsonrpc: String,
pub method: String,
pub params: Value,
pub id: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
pub result: Option<Value>,
pub error: Option<JsonRpcError>,
pub id: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
pub code: i32,
pub message: String,
pub data: Option<Value>,
}
/// WebSocket client for communicating with the Hero WebSocket server
#[derive(Clone)]
pub struct WebSocketClient {
ws_stream: Arc<Mutex<Option<WsStream>>>,
url: String,
}
impl WebSocketClient {
/// Create a new WebSocket client and connect to the server
pub async fn new(url: &str) -> Result<Self, ProxyError> {
let client = Self {
ws_stream: Arc::new(Mutex::new(None)),
url: url.to_string(),
};
client.connect().await?;
Ok(client)
}
/// Connect to the WebSocket server
async fn connect(&self) -> Result<(), ProxyError> {
let url = Url::parse(&self.url)
.map_err(|e| ProxyError::WebSocketError(format!("Invalid URL: {}", e)))?;
info!("Connecting to WebSocket server: {}", self.url);
let (ws_stream, _) = connect_async(url).await
.map_err(|e| ProxyError::WebSocketError(format!("Connection failed: {}", e)))?;
let mut stream_guard = self.ws_stream.lock().await;
*stream_guard = Some(ws_stream);
info!("Successfully connected to WebSocket server");
Ok(())
}
/// Check if the WebSocket connection is still active
pub async fn is_connected(&self) -> bool {
let stream_guard = self.ws_stream.lock().await;
stream_guard.is_some()
}
/// Send a JSON-RPC request and wait for response
pub async fn send_jsonrpc_request(
&self,
method: &str,
params: Value,
request_id: Value,
) -> Result<JsonRpcResponse, ProxyError> {
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
method: method.to_string(),
params,
id: request_id.clone(),
};
let request_json = serde_json::to_string(&request)?;
debug!("Sending JSON-RPC request: {}", request_json);
// Send the request
{
let mut stream_guard = self.ws_stream.lock().await;
let stream = stream_guard.as_mut()
.ok_or_else(|| ProxyError::WebSocketError("Not connected".to_string()))?;
stream.send(Message::Text(request_json)).await
.map_err(|e| ProxyError::WebSocketError(format!("Send failed: {}", e)))?;
}
// Wait for response with timeout
let response = timeout(Duration::from_secs(30), self.receive_response(request_id)).await
.map_err(|_| ProxyError::TimeoutError("WebSocket request timeout".to_string()))??;
Ok(response)
}
/// Receive a JSON-RPC response matching the given request ID
async fn receive_response(&self, expected_id: Value) -> Result<JsonRpcResponse, ProxyError> {
loop {
let message = {
let mut stream_guard = self.ws_stream.lock().await;
let stream = stream_guard.as_mut()
.ok_or_else(|| ProxyError::WebSocketError("Not connected".to_string()))?;
stream.next().await
.ok_or_else(|| ProxyError::WebSocketError("Connection closed".to_string()))?
.map_err(|e| ProxyError::WebSocketError(format!("Receive failed: {}", e)))?
};
match message {
Message::Text(text) => {
debug!("Received WebSocket message: {}", text);
let response: JsonRpcResponse = serde_json::from_str(&text)
.map_err(|e| ProxyError::JsonRpcError(format!("Invalid JSON-RPC response: {}", e)))?;
// Check if this is the response we're waiting for
if response.id == expected_id {
return Ok(response);
} else {
warn!("Received response with unexpected ID: {:?}, expected: {:?}", response.id, expected_id);
}
}
Message::Close(_) => {
return Err(ProxyError::WebSocketError("Connection closed by server".to_string()));
}
Message::Ping(data) => {
// Respond to ping with pong
let mut stream_guard = self.ws_stream.lock().await;
let stream = stream_guard.as_mut()
.ok_or_else(|| ProxyError::WebSocketError("Not connected".to_string()))?;
stream.send(Message::Pong(data)).await
.map_err(|e| ProxyError::WebSocketError(format!("Pong failed: {}", e)))?;
}
_ => {
// Ignore other message types
debug!("Ignoring WebSocket message type: {:?}", message);
}
}
}
}
/// Execute a "play" command via JSON-RPC
pub async fn execute_play(
&self,
circle_id: &str,
caller_id: &str,
script: &str,
) -> Result<Value, ProxyError> {
let params = serde_json::json!({
"circle_id": circle_id,
"caller_id": caller_id,
"script": script
});
let request_id = serde_json::json!(format!("play_{}", uuid::Uuid::new_v4()));
let response = self.send_jsonrpc_request("play", params, request_id).await?;
if let Some(error) = response.error {
return Err(ProxyError::JsonRpcError(format!(
"JSON-RPC error {}: {}",
error.code,
error.message
)));
}
response.result.ok_or_else(|| {
ProxyError::JsonRpcError("No result in JSON-RPC response".to_string())
})
}
}
// Add uuid dependency to Cargo.toml for generating request IDs