syntaxis/core/crates/cli/src/api_client.rs
Jesús Pérez 9cef9b8d57 refactor: consolidate configuration directories
Merge _configs/ into config/ for single configuration directory.
Update all path references.

Changes:
- Move _configs/* to config/
- Update .gitignore for new patterns
- No code references to _configs/ found

Impact: -1 root directory (layout_conventions.md compliance)
2025-12-26 18:36:23 +00:00

383 lines
10 KiB
Rust

//! 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<String>,
/// 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<Self> {
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<HealthStatus> {
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::<HealthStatus>()
.await
.context("Failed to parse health response")
}
/// Get a project by ID
#[allow(dead_code)]
pub async fn get_project(&self, id: &str) -> Result<ProjectResponse> {
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::<ProjectResponse>()
.await
.context("Failed to parse project response")
}
/// List all projects
pub async fn list_projects(&self) -> Result<Vec<ProjectResponse>> {
let url = format!("{}/api/projects", self.config.base_url);
let response = self
.client
.get(&url)
.send()
.await
.context("Failed to list projects")?;
response
.json::<Vec<ProjectResponse>>()
.await
.context("Failed to parse projects list")
}
/// Get current project phase
pub async fn get_current_phase(&self, project_id: &str) -> Result<PhaseResponse> {
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::<PhaseResponse>()
.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<PhaseResponse> {
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::<PhaseResponse>()
.await
.context("Failed to parse transition response")
}
/// Get project status
pub async fn get_project_status(&self, project_id: &str) -> Result<StatusResponse> {
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::<StatusResponse>()
.await
.context("Failed to parse status response")
}
/// Run security assessment
pub async fn run_security_assessment(&self, project_id: &str) -> Result<SecurityResponse> {
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::<SecurityResponse>()
.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<String>,
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<SecurityFinding>,
}
/// 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);
}
}