//! HTTP API client for remote syntaxis-api communication //! //! This module provides a client for communicating with the syntaxis-api server. //! The CLI can operate in two modes: //! - Local mode: Direct database access (default) //! - Remote mode: Via API endpoints when configured use anyhow::{Context, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::time::Duration; /// API client configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiConfig { /// Enable remote API mode (false = local mode) pub enabled: bool, /// API server base URL pub base_url: String, /// JWT authentication token (optional) pub auth_token: Option, /// Request timeout in seconds pub timeout_secs: u64, } impl Default for ApiConfig { fn default() -> Self { Self { enabled: false, base_url: "http://localhost:3000".to_string(), auth_token: None, timeout_secs: 30, } } } /// HTTP API client for syntaxis-api communication #[derive(Debug, Clone)] pub struct ApiClient { client: Client, config: ApiConfig, } impl ApiClient { /// Create a new API client with the given configuration pub fn new(config: ApiConfig) -> Result { let timeout = Duration::from_secs(config.timeout_secs); let client = Client::builder() .timeout(timeout) .build() .context("Failed to create HTTP client")?; Ok(Self { client, config }) } /// Check if API mode is enabled #[allow(dead_code)] pub fn is_enabled(&self) -> bool { self.config.enabled } /// Get the API base URL #[allow(dead_code)] pub fn base_url(&self) -> &str { &self.config.base_url } /// Check server health pub async fn health_check(&self) -> Result { let url = format!("{}/api/health", self.config.base_url); let response = self .client .get(&url) .send() .await .context("Failed to connect to health endpoint")?; response .json::() .await .context("Failed to parse health response") } /// Get a project by ID #[allow(dead_code)] pub async fn get_project(&self, id: &str) -> Result { let url = format!("{}/api/projects/{}", self.config.base_url, id); let response = self .client .get(&url) .send() .await .context("Failed to get project")?; response .json::() .await .context("Failed to parse project response") } /// List all projects pub async fn list_projects(&self) -> Result> { let url = format!("{}/api/projects", self.config.base_url); let response = self .client .get(&url) .send() .await .context("Failed to list projects")?; response .json::>() .await .context("Failed to parse projects list") } /// Get current project phase pub async fn get_current_phase(&self, project_id: &str) -> Result { let url = format!( "{}/api/projects/{}/phases", self.config.base_url, project_id ); let response = self .client .get(&url) .send() .await .context("Failed to get current phase")?; response .json::() .await .context("Failed to parse phase response") } /// Transition project to a new phase pub async fn transition_phase( &self, project_id: &str, target_phase: &str, ) -> Result { let url = format!( "{}/api/projects/{}/phases/transition", self.config.base_url, project_id ); #[derive(Serialize)] struct TransitionRequest { target_phase: String, } let response = self .client .post(&url) .json(&TransitionRequest { target_phase: target_phase.to_string(), }) .send() .await .context("Failed to transition phase")?; response .json::() .await .context("Failed to parse transition response") } /// Get project status pub async fn get_project_status(&self, project_id: &str) -> Result { let url = format!( "{}/api/projects/{}/status", self.config.base_url, project_id ); let response = self .client .get(&url) .send() .await .context("Failed to get project status")?; response .json::() .await .context("Failed to parse status response") } /// Run security assessment pub async fn run_security_assessment(&self, project_id: &str) -> Result { let url = format!( "{}/api/projects/{}/security/assess", self.config.base_url, project_id ); let response = self .client .post(&url) .send() .await .context("Failed to run security assessment")?; response .json::() .await .context("Failed to parse security response") } } // Response types from API /// Health status response #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthStatus { pub status: String, pub service: String, pub version: String, pub uptime: UptimeInfo, pub database: DatabaseInfo, pub timestamp: String, } /// Uptime information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UptimeInfo { pub seconds: u64, pub formatted: String, } /// Database information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DatabaseInfo { pub connected: bool, #[serde(rename = "type")] pub db_type: String, } /// Project response from API #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectResponse { pub id: String, pub name: String, pub description: Option, pub phase: String, pub status: String, pub created_at: String, pub updated_at: String, } /// Phase response from API #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PhaseResponse { pub project_id: String, pub phase: String, pub transitioned_at: String, } /// Status response from API #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatusResponse { pub project_id: String, pub phase: String, pub completion_percentage: f64, pub checklist_items: ChecklistStats, pub tools_enabled: u32, pub tools_disabled: u32, } /// Checklist statistics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChecklistStats { pub total: u32, pub completed: u32, pub pending: u32, } /// Security assessment response #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecurityResponse { pub project_id: String, pub severity: String, pub assessment_date: String, pub findings: Vec, } /// Security finding #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecurityFinding { pub id: String, pub title: String, pub severity: String, pub description: String, } #[cfg(test)] mod tests { use super::*; #[test] fn test_api_config_default() { let config = ApiConfig::default(); assert!(!config.enabled); assert_eq!(config.base_url, "http://localhost:3000"); assert_eq!(config.timeout_secs, 30); } #[test] fn test_api_client_creation() { let config = ApiConfig::default(); let client = ApiClient::new(config); assert!(client.is_ok()); } #[test] fn test_api_client_is_enabled() { let mut config = ApiConfig::default(); assert!(!config.enabled); config.enabled = true; let client = ApiClient::new(config).unwrap(); assert!(client.is_enabled()); } #[test] fn test_api_client_base_url() { let config = ApiConfig { base_url: "https://api.example.com".to_string(), ..Default::default() }; let client = ApiClient::new(config).unwrap(); assert_eq!(client.base_url(), "https://api.example.com"); } #[test] fn test_health_status_serialization() { let json = r#"{ "status": "healthy", "service": "syntaxis-api", "version": "1.0.0", "uptime": { "seconds": 3600, "formatted": "1h 0m" }, "database": { "connected": true, "type": "sqlite" }, "timestamp": "2025-11-13T00:00:00Z" }"#; let status: HealthStatus = serde_json::from_str(json).unwrap(); assert_eq!(status.status, "healthy"); assert_eq!(status.version, "1.0.0"); assert!(status.database.connected); } #[test] fn test_project_response_serialization() { let json = r#"{ "id": "proj-1", "name": "Test Project", "description": "A test project", "phase": "development", "status": "active", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-11-13T00:00:00Z" }"#; let project: ProjectResponse = serde_json::from_str(json).unwrap(); assert_eq!(project.id, "proj-1"); assert_eq!(project.name, "Test Project"); assert_eq!(project.phase, "development"); } #[test] fn test_status_response_serialization() { let json = r#"{ "project_id": "proj-1", "phase": "development", "completion_percentage": 75.5, "checklist_items": { "total": 10, "completed": 7, "pending": 3 }, "tools_enabled": 5, "tools_disabled": 2 }"#; let status: StatusResponse = serde_json::from_str(json).unwrap(); assert_eq!(status.project_id, "proj-1"); assert_eq!(status.completion_percentage, 75.5); assert_eq!(status.checklist_items.completed, 7); } }