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)
383 lines
10 KiB
Rust
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);
|
|
}
|
|
}
|