//! Osiris Client - Unified CQRS Client //! //! This client provides both: //! - Commands (writes) via Rhai scripts to Hero Supervisor //! - Queries (reads) via REST API to Osiris server //! //! Follows CQRS pattern with a single unified interface. use serde::{Deserialize, Serialize}; use std::collections::HashMap; use thiserror::Error; pub mod kyc; pub mod payment; pub mod communication; pub use kyc::*; pub use payment::*; pub use communication::*; #[derive(Debug, Error)] pub enum OsirisClientError { #[error("HTTP request failed: {0}")] RequestFailed(#[from] reqwest::Error), #[error("Resource not found: {0}")] NotFound(String), #[error("Deserialization failed: {0}")] DeserializationFailed(String), #[error("Configuration error: {0}")] ConfigError(String), #[error("Command execution failed: {0}")] CommandFailed(String), } /// Osiris client with CQRS support #[derive(Clone)] pub struct OsirisClient { // Query side (Osiris REST API) osiris_url: String, // Command side (Supervisor + Rhai) supervisor_client: Option, runner_name: String, timeout: u64, // HTTP client client: reqwest::Client, } /// Builder for OsirisClient #[derive(Clone, Debug, Default)] pub struct OsirisClientBuilder { osiris_url: Option, supervisor_url: Option, runner_name: Option, supervisor_secret: Option, timeout: u64, } impl OsirisClientBuilder { /// Create a new builder pub fn new() -> Self { Self { osiris_url: None, supervisor_url: None, runner_name: None, supervisor_secret: None, timeout: 30, } } /// Set the Osiris server URL (for queries) pub fn osiris_url(mut self, url: impl Into) -> Self { self.osiris_url = Some(url.into()); self } /// Set the Supervisor URL (for commands) pub fn supervisor_url(mut self, url: impl Into) -> Self { self.supervisor_url = Some(url.into()); self } /// Set the runner name (default: "osiris") pub fn runner_name(mut self, name: impl Into) -> Self { self.runner_name = Some(name.into()); self } /// Set the supervisor authentication secret pub fn supervisor_secret(mut self, secret: impl Into) -> Self { self.supervisor_secret = Some(secret.into()); self } /// Set the timeout in seconds (default: 30) pub fn timeout(mut self, timeout: u64) -> Self { self.timeout = timeout; self } /// Build the OsirisClient pub fn build(self) -> Result { let osiris_url = self.osiris_url .ok_or_else(|| OsirisClientError::ConfigError("osiris_url is required".to_string()))?; // Build supervisor client if URL and secret are provided let supervisor_client = if let (Some(url), Some(secret)) = (self.supervisor_url, self.supervisor_secret) { Some( hero_supervisor_openrpc_client::SupervisorClient::builder() .url(url) .secret(secret) .build() .map_err(|e| OsirisClientError::ConfigError(format!("Failed to create supervisor client: {:?}", e)))? ) } else { None }; Ok(OsirisClient { osiris_url, supervisor_client, runner_name: self.runner_name.unwrap_or_else(|| "osiris".to_string()), timeout: self.timeout, client: reqwest::Client::new(), }) } } impl OsirisClient { /// Create a new Osiris client (query-only) pub fn new(osiris_url: impl Into) -> Self { Self { osiris_url: osiris_url.into(), supervisor_client: None, runner_name: "osiris".to_string(), timeout: 30, client: reqwest::Client::new(), } } /// Create a builder for full CQRS configuration pub fn builder() -> OsirisClientBuilder { OsirisClientBuilder::new() } /// Generic GET request for any struct by ID pub async fn get(&self, struct_name: &str, id: &str) -> Result where T: for<'de> Deserialize<'de>, { let url = format!("{}/api/{}/{}", self.osiris_url, struct_name, id); let response = self.client .get(&url) .send() .await?; if response.status() == 404 { return Err(OsirisClientError::NotFound(format!("{}/{}", struct_name, id))); } let data = response .json::() .await .map_err(|e| OsirisClientError::DeserializationFailed(e.to_string()))?; Ok(data) } /// Generic LIST request for all instances of a struct pub async fn list(&self, struct_name: &str) -> Result, OsirisClientError> where T: for<'de> Deserialize<'de>, { let url = format!("{}/api/{}", self.osiris_url, struct_name); let response = self.client .get(&url) .send() .await?; let data = response .json::>() .await .map_err(|e| OsirisClientError::DeserializationFailed(e.to_string()))?; Ok(data) } /// Generic QUERY request with filters pub async fn query(&self, struct_name: &str, query: &str) -> Result, OsirisClientError> where T: for<'de> Deserialize<'de>, { let url = format!("{}/api/{}?{}", self.osiris_url, struct_name, query); let response = self.client .get(&url) .send() .await?; let data = response .json::>() .await .map_err(|e| OsirisClientError::DeserializationFailed(e.to_string()))?; Ok(data) } // ========== Command Methods (Supervisor + Rhai) ========== // Commands are write operations that execute Rhai scripts via the supervisor // to modify state in Osiris /// Execute a Rhai script via the Supervisor pub async fn execute_script(&self, script: &str) -> Result { let supervisor_client = self.supervisor_client.as_ref() .ok_or_else(|| OsirisClientError::ConfigError("supervisor_client not configured for commands".to_string()))?; // Use JobBuilder from supervisor client (which re-exports from hero-job) use hero_supervisor_openrpc_client::JobBuilder; let job = JobBuilder::new() .caller_id("osiris-client") .context_id("command-execution") .runner(&self.runner_name) .payload(script) .timeout(self.timeout) .build() .map_err(|e| OsirisClientError::CommandFailed(format!("Failed to build job: {}", e)))?; // Use job_run method which returns JobRunResponse // Secret is sent via Authorization header (configured during client creation) let result = supervisor_client.job_run(job, Some(self.timeout)) .await .map_err(|e| OsirisClientError::CommandFailed(format!("{:?}", e)))?; // Convert JobRunResponse to our RunJobResponse Ok(RunJobResponse { job_id: result.job_id, status: result.status, }) } /// Execute a Rhai script template with variable substitution pub async fn execute_template(&self, template: &str, variables: &HashMap) -> Result { let script = substitute_variables(template, variables); self.execute_script(&script).await } // ========== Supervisor-specific CQRS Methods ========== /// Create an API key (Command - via Rhai) pub async fn create_api_key(&self, key: String, name: String, scope: String) -> Result { let script = format!( r#" let api_key = new_api_key("{}", "{}", "{}", "{}"); save_api_key(api_key); "#, self.get_namespace(), key, name, scope ); self.execute_script(&script).await } /// Get an API key by key value (Query - via REST) pub async fn get_api_key(&self, key: &str) -> Result, OsirisClientError> { // Query by indexed field let results: Vec = self.query("ApiKey", &format!("key={}", key)).await?; Ok(results.into_iter().next()) } /// List all API keys (Query - via REST) pub async fn list_api_keys(&self) -> Result, OsirisClientError> { self.list("ApiKey").await } /// Delete an API key (Command - via Rhai) pub async fn delete_api_key(&self, key: String) -> Result { let script = format!( r#" delete_api_key("{}"); "#, key ); self.execute_script(&script).await } /// Create a runner (Command - via Rhai) pub async fn create_runner(&self, runner_id: String, name: String, queue: String, registered_by: String) -> Result { let script = format!( r#" let runner = new_runner("{}", "{}", "{}", "{}", "{}"); save_runner(runner); "#, self.get_namespace(), runner_id, name, queue, registered_by ); self.execute_script(&script).await } /// Get a runner by ID (Query - via REST) pub async fn get_runner(&self, runner_id: &str) -> Result, OsirisClientError> { let results: Vec = self.query("Runner", &format!("runner_id={}", runner_id)).await?; Ok(results.into_iter().next()) } /// List all runners (Query - via REST) pub async fn list_runners(&self) -> Result, OsirisClientError> { self.list("Runner").await } /// Delete a runner (Command - via Rhai) pub async fn delete_runner(&self, runner_id: String) -> Result { let script = format!( r#" delete_runner("{}"); "#, runner_id ); self.execute_script(&script).await } /// Create job metadata (Command - via Rhai) pub async fn create_job_metadata(&self, job_id: String, runner: String, created_by: String, payload: String) -> Result { let script = format!( r#" let job = new_job_metadata("{}", "{}", "{}", "{}", "{}"); save_job_metadata(job); "#, self.get_namespace(), job_id, runner, created_by, payload ); self.execute_script(&script).await } /// Get job metadata by ID (Query - via REST) pub async fn get_job_metadata(&self, job_id: &str) -> Result, OsirisClientError> { let results: Vec = self.query("JobMetadata", &format!("job_id={}", job_id)).await?; Ok(results.into_iter().next()) } /// List all job metadata (Query - via REST) pub async fn list_job_metadata(&self) -> Result, OsirisClientError> { self.list("JobMetadata").await } /// List jobs by runner (Query - via REST) pub async fn list_jobs_by_runner(&self, runner: &str) -> Result, OsirisClientError> { self.query("JobMetadata", &format!("runner={}", runner)).await } /// List jobs by creator (Query - via REST) pub async fn list_jobs_by_creator(&self, creator: &str) -> Result, OsirisClientError> { self.query("JobMetadata", &format!("created_by={}", creator)).await } // Helper method to get namespace fn get_namespace(&self) -> &str { "supervisor" } } // ========== Helper Structures ========== #[derive(Serialize)] struct RunJobRequest { runner_name: String, script: String, timeout: Option, #[serde(skip_serializing_if = "Option::is_none")] env: Option>, } #[derive(Deserialize, Debug, Clone)] pub struct RunJobResponse { pub job_id: String, pub status: String, } /// Helper function to substitute variables in a Rhai script template pub fn substitute_variables(template: &str, variables: &HashMap) -> String { let mut result = template.to_string(); for (key, value) in variables { let placeholder = format!("{{{{ {} }}}}", key); result = result.replace(&placeholder, value); } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_client_creation() { let client = OsirisClient::new("http://localhost:8080"); assert_eq!(client.osiris_url, "http://localhost:8080"); } #[test] fn test_builder() { let client = OsirisClient::builder() .osiris_url("http://localhost:8081") .supervisor_url("http://localhost:3030") .supervisor_secret("test_secret") .runner_name("osiris") .build() .unwrap(); assert_eq!(client.osiris_url, "http://localhost:8081"); assert_eq!(client.supervisor_url, Some("http://localhost:3030".to_string())); assert_eq!(client.runner_name, "osiris"); } #[test] fn test_substitute_variables() { let template = "let x = {{ value }}; let y = {{ name }};"; let mut vars = HashMap::new(); vars.insert("value".to_string(), "42".to_string()); vars.insert("name".to_string(), "\"test\"".to_string()); let result = substitute_variables(template, &vars); assert_eq!(result, "let x = 42; let y = \"test\";"); } }