vex_api/
routes.rs

1//! API routes for VEX endpoints
2
3use axum::{
4    extract::{Extension, Path, State},
5    routing::{get, post},
6    Json, Router,
7};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::auth::Claims;
12use crate::error::{ApiError, ApiResult};
13use crate::sanitize::{sanitize_name, sanitize_prompt_async, sanitize_role};
14use crate::state::AppState;
15use utoipa::OpenApi;
16use vex_persist::AgentStore;
17
18/// Health check response
19#[derive(Debug, Serialize, utoipa::ToSchema)]
20pub struct HealthResponse {
21    pub status: String,
22    pub version: String,
23    pub timestamp: chrono::DateTime<chrono::Utc>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub components: Option<ComponentHealth>,
26}
27
28/// Component health status
29#[derive(Debug, Serialize, utoipa::ToSchema)]
30pub struct ComponentHealth {
31    pub database: ComponentStatus,
32    pub queue: ComponentStatus,
33}
34
35/// Individual component status
36#[derive(Debug, Serialize, utoipa::ToSchema)]
37pub struct ComponentStatus {
38    pub status: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub latency_ms: Option<u64>,
41}
42
43/// Basic health check handler (lightweight)
44#[utoipa::path(
45    get,
46    path = "/health",
47    responses(
48        (status = 200, description = "Basic health check", body = HealthResponse)
49    )
50)]
51pub async fn health() -> Json<HealthResponse> {
52    Json(HealthResponse {
53        status: "healthy".to_string(),
54        version: env!("CARGO_PKG_VERSION").to_string(),
55        timestamp: chrono::Utc::now(),
56        components: None,
57    })
58}
59
60/// Detailed health check with database connectivity
61#[utoipa::path(
62    get,
63    path = "/health/detailed",
64    responses(
65        (status = 200, description = "Detailed health check with component status", body = HealthResponse)
66    )
67)]
68pub async fn health_detailed(State(state): State<AppState>) -> Json<HealthResponse> {
69    let start = std::time::Instant::now();
70
71    // Check database
72    let db_healthy = state.db().is_healthy().await;
73    let db_latency = start.elapsed().as_millis() as u64;
74
75    // Queue is always healthy (in-memory)
76    let queue_status = ComponentStatus {
77        status: "healthy".to_string(),
78        latency_ms: Some(0),
79    };
80
81    let db_status = ComponentStatus {
82        status: if db_healthy { "healthy" } else { "unhealthy" }.to_string(),
83        latency_ms: Some(db_latency),
84    };
85
86    let overall_status = if db_healthy { "healthy" } else { "degraded" };
87
88    Json(HealthResponse {
89        status: overall_status.to_string(),
90        version: env!("CARGO_PKG_VERSION").to_string(),
91        timestamp: chrono::Utc::now(),
92        components: Some(ComponentHealth {
93            database: db_status,
94            queue: queue_status,
95        }),
96    })
97}
98
99/// Agent creation request
100#[derive(Debug, Deserialize, utoipa::ToSchema)]
101pub struct CreateAgentRequest {
102    pub name: String,
103    pub role: String,
104    #[serde(default = "default_max_depth")]
105    pub max_depth: u8,
106    #[serde(default)]
107    pub spawn_shadow: bool,
108}
109
110fn default_max_depth() -> u8 {
111    3
112}
113
114/// Agent response
115#[derive(Debug, Serialize, utoipa::ToSchema)]
116pub struct AgentResponse {
117    pub id: Uuid,
118    pub name: String,
119    pub role: String,
120    pub generation: u32,
121    pub fitness: f64,
122    pub created_at: chrono::DateTime<chrono::Utc>,
123}
124
125/// Create agent handler
126#[utoipa::path(
127    post,
128    path = "/api/v1/agents",
129    request_body = CreateAgentRequest,
130    responses(
131        (status = 200, description = "Agent created successfully", body = AgentResponse),
132        (status = 403, description = "Insufficient permissions"),
133        (status = 400, description = "Invalid input")
134    ),
135    security(
136        ("jwt" = [])
137    )
138)]
139pub async fn create_agent(
140    Extension(claims): Extension<Claims>,
141    State(state): State<AppState>,
142    Json(req): Json<CreateAgentRequest>,
143) -> ApiResult<Json<AgentResponse>> {
144    // Validate role access
145    if !claims.has_role("user") {
146        return Err(ApiError::Forbidden("Insufficient permissions".to_string()));
147    }
148
149    // Sanitize inputs
150    let name = sanitize_name(&req.name)
151        .map_err(|e| ApiError::Validation(format!("Invalid name: {}", e)))?;
152    let role = sanitize_role(&req.role)
153        .map_err(|e| ApiError::Validation(format!("Invalid role: {}", e)))?;
154
155    // Validate depth bounds (Fix #13)
156    if req.max_depth > 10 {
157        return Err(ApiError::Validation(
158            "max_depth exceeds safety limit of 10".to_string(),
159        ));
160    }
161
162    // Create agent with sanitized inputs
163    let config = vex_core::AgentConfig {
164        name: name.clone(),
165        role: role.clone(),
166        max_depth: req.max_depth,
167        spawn_shadow: req.spawn_shadow,
168    };
169    let agent = vex_core::Agent::new(config);
170
171    // Persist agent with tenant isolation
172    let store = AgentStore::new(state.db());
173
174    store
175        .save(&claims.sub, &agent)
176        .await
177        .map_err(|e| ApiError::Internal(format!("Failed to save agent: {}", e)))?;
178
179    // Record metrics
180    state.metrics().record_agent_created();
181
182    Ok(Json(AgentResponse {
183        id: agent.id,
184        name: req.name,
185        role: req.role,
186        generation: agent.generation,
187        fitness: agent.fitness,
188        created_at: chrono::Utc::now(),
189    }))
190}
191
192/// Execute agent request
193#[derive(Debug, Deserialize, utoipa::ToSchema)]
194pub struct ExecuteRequest {
195    pub prompt: String,
196    #[serde(default)]
197    pub enable_adversarial: bool,
198    #[serde(default = "default_max_rounds")]
199    pub max_debate_rounds: u32,
200}
201
202fn default_max_rounds() -> u32 {
203    3
204}
205
206/// Execute agent response
207#[derive(Debug, Serialize, utoipa::ToSchema)]
208pub struct ExecuteResponse {
209    pub agent_id: Uuid,
210    pub response: String,
211    pub verified: bool,
212    pub confidence: f64,
213    pub context_hash: String,
214    pub latency_ms: u64,
215}
216
217/// Execute agent handler
218#[utoipa::path(
219    post,
220    path = "/api/v1/agents/{id}/execute",
221    params(
222        ("id" = Uuid, Path, description = "Agent ID")
223    ),
224    request_body = ExecuteRequest,
225    responses(
226        (status = 200, description = "Job queued successfully", body = ExecuteResponse),
227        (status = 404, description = "Agent not found")
228    ),
229    security(
230        ("jwt" = [])
231    )
232)]
233pub async fn execute_agent(
234    Extension(claims): Extension<Claims>,
235    State(state): State<AppState>,
236    Path(agent_id): Path<Uuid>,
237    Json(req): Json<ExecuteRequest>,
238) -> ApiResult<Json<ExecuteResponse>> {
239    let start = std::time::Instant::now();
240
241    // Sanitize and validate prompt with async safety judge
242    let llm = state.llm();
243    let prompt = sanitize_prompt_async(&req.prompt, Some(&*llm))
244        .await
245        .map_err(|e| ApiError::Validation(format!("Invalid prompt: {}", e)))?;
246
247    // Check ownership/existence
248    let store = AgentStore::new(state.db());
249
250    let exists = store
251        .exists(&claims.sub, agent_id)
252        .await
253        .map_err(|e| ApiError::Internal(format!("Storage error: {}", e)))?;
254
255    if !exists {
256        return Err(ApiError::NotFound("Agent not found".to_string()));
257    }
258
259    // Create job payload with sanitized prompt
260    let payload = serde_json::json!({
261        "agent_id": agent_id,
262        "prompt": prompt,
263        "config": {
264            "enable_adversarial": req.enable_adversarial,
265            "max_rounds": req.max_debate_rounds
266        },
267        "tenant_id": claims.sub
268    });
269
270    // Enqueue job with explicit type checks
271    // Enqueue job via dynamic backend
272    let pool = state.queue();
273
274    // For dynamic dispatch, we access the backend. It's Arc<dyn QueueBackend>.
275    let backend = &pool.backend;
276
277    let job_id = backend
278        .enqueue(&claims.sub, "agent_execution", payload, None)
279        .await
280        .map_err(|e| ApiError::Internal(format!("Queue error: {}", e)))?;
281
282    // Record metrics
283    state.metrics().record_llm_call(0, false); // Just counting requests for now
284
285    Ok(Json(ExecuteResponse {
286        agent_id,
287        response: format!("Job queued: {}", job_id),
288        verified: false,
289        confidence: 1.0,
290        context_hash: "pending".to_string(),
291        latency_ms: start.elapsed().as_millis() as u64,
292    }))
293}
294
295/// Job status response (for polling after execute)
296#[derive(Debug, Serialize, utoipa::ToSchema)]
297pub struct JobStatusResponse {
298    pub job_id: Uuid,
299    pub status: String,
300    pub result: Option<serde_json::Value>,
301    pub error: Option<String>,
302    pub queued_at: chrono::DateTime<chrono::Utc>,
303    pub attempts: u32,
304}
305
306/// Get job status / result handler
307#[utoipa::path(
308    get,
309    path = "/api/v1/jobs/{id}",
310    params(
311        ("id" = Uuid, Path, description = "Job ID returned from execute_agent")
312    ),
313    responses(
314        (status = 200, description = "Job status and result", body = JobStatusResponse),
315        (status = 404, description = "Job not found")
316    ),
317    security(
318        ("jwt" = [])
319    )
320)]
321pub async fn get_job_status(
322    Extension(claims): Extension<Claims>,
323    State(state): State<AppState>,
324    Path(job_id): Path<Uuid>,
325) -> ApiResult<Json<JobStatusResponse>> {
326    let pool = state.queue();
327    let backend = &pool.backend;
328
329    let tenant_id = claims.tenant_id.as_deref().unwrap_or(&claims.sub);
330
331    let job = backend
332        .get_job(tenant_id, job_id)
333        .await
334        .map_err(|_| ApiError::NotFound(format!("Job {} not found", job_id)))?;
335
336    let status_str = match job.status {
337        vex_queue::JobStatus::Pending => "pending",
338        vex_queue::JobStatus::Running => "running",
339        vex_queue::JobStatus::Completed => "completed",
340        vex_queue::JobStatus::Failed(_) => "failed",
341        vex_queue::JobStatus::DeadLetter => "dead_letter",
342    };
343
344    Ok(Json(JobStatusResponse {
345        job_id,
346        status: status_str.to_string(),
347        result: job.result,
348        error: job.last_error,
349        queued_at: job.created_at,
350        attempts: job.attempts,
351    }))
352}
353
354/// Metrics response
355#[derive(Debug, Serialize, utoipa::ToSchema)]
356pub struct MetricsResponse {
357    pub llm_calls: u64,
358    pub llm_errors: u64,
359    pub tokens_used: u64,
360    pub debates: u64,
361    pub agents_created: u64,
362    pub verifications: u64,
363    pub verification_rate: f64,
364    pub error_rate: f64,
365}
366
367/// Get metrics handler
368#[utoipa::path(
369    get,
370    path = "/api/v1/metrics",
371    responses(
372        (status = 200, description = "Current system metrics", body = MetricsResponse),
373        (status = 403, description = "Forbidden")
374    ),
375    security(
376        ("jwt" = [])
377    )
378)]
379pub async fn get_metrics(
380    Extension(claims): Extension<Claims>,
381    State(state): State<AppState>,
382) -> ApiResult<Json<MetricsResponse>> {
383    // Only admins can view metrics
384    if !claims.has_role("admin") {
385        return Err(ApiError::Forbidden("Admin access required".to_string()));
386    }
387
388    let snapshot = state.metrics().snapshot();
389
390    Ok(Json(MetricsResponse {
391        llm_calls: snapshot.llm_calls,
392        llm_errors: snapshot.llm_errors,
393        tokens_used: snapshot.tokens_used,
394        debates: snapshot.debates,
395        agents_created: snapshot.agents_created,
396        verifications: snapshot.verifications,
397        verification_rate: state.metrics().verification_rate(),
398        error_rate: state.metrics().llm_error_rate(),
399    }))
400}
401
402/// Routing statistics response
403#[derive(Debug, Serialize, utoipa::ToSchema)]
404pub struct RoutingStatsResponse {
405    pub summary: vex_router::ObservabilitySummary,
406    pub savings: vex_router::SavingsReport,
407}
408
409/// Get routing statistics handler
410#[utoipa::path(
411    get,
412    path = "/api/v1/routing/stats",
413    responses(
414        (status = 200, description = "Current routing statistics and cost savings", body = RoutingStatsResponse),
415        (status = 404, description = "Router not enabled"),
416        (status = 403, description = "Forbidden")
417    ),
418    security(
419        ("jwt" = [])
420    )
421)]
422pub async fn get_routing_stats(
423    Extension(claims): Extension<Claims>,
424    State(state): State<AppState>,
425) -> ApiResult<Json<RoutingStatsResponse>> {
426    // Only admins can view deep stats
427    if !claims.has_role("admin") {
428        return Err(ApiError::Forbidden("Admin access required".to_string()));
429    }
430
431    let router = state
432        .router()
433        .ok_or_else(|| ApiError::NotFound("Router not enabled".to_string()))?;
434    let obs = router.observability();
435
436    Ok(Json(RoutingStatsResponse {
437        summary: obs.get_summary(),
438        savings: obs.get_savings(),
439    }))
440}
441
442/// Routing configuration request
443#[derive(Debug, Deserialize, utoipa::ToSchema)]
444pub struct UpdateRoutingConfigRequest {
445    pub strategy: String,
446    pub cache_enabled: bool,
447    pub compression_level: String,
448}
449
450/// Update routing configuration handler
451#[utoipa::path(
452    put,
453    path = "/api/v1/routing/config",
454    request_body = UpdateRoutingConfigRequest,
455    responses(
456        (status = 200, description = "Routing configuration updated successfully"),
457        (status = 404, description = "Router not enabled"),
458        (status = 400, description = "Invalid configuration"),
459        (status = 403, description = "Forbidden")
460    ),
461    security(
462        ("jwt" = [])
463    )
464)]
465pub async fn update_routing_config(
466    Extension(claims): Extension<Claims>,
467    State(state): State<AppState>,
468    Json(req): Json<UpdateRoutingConfigRequest>,
469) -> ApiResult<Json<HealthResponse>> {
470    // Only admins can change system config
471    if !claims.has_role("admin") {
472        return Err(ApiError::Forbidden("Admin access required".to_string()));
473    }
474
475    let _router = state
476        .router()
477        .ok_or_else(|| ApiError::NotFound("Router not enabled".to_string()))?;
478
479    // In a real implementation, we would update the router state here.
480    // For now, we return a success status.
481
482    Ok(Json(HealthResponse {
483        status: format!("Routing strategy updated to {}", req.strategy),
484        version: env!("CARGO_PKG_VERSION").to_string(),
485        timestamp: chrono::Utc::now(),
486        components: None,
487    }))
488}
489
490/// Prometheus metrics handler
491#[utoipa::path(
492    get,
493    path = "/metrics",
494    responses(
495        (status = 200, description = "Prometheus formatted metrics", body = String)
496    )
497)]
498pub async fn get_prometheus_metrics(
499    Extension(claims): Extension<Claims>,
500    State(state): State<AppState>,
501) -> ApiResult<String> {
502    // Only admins can view metrics
503    if !claims.has_role("admin") {
504        return Err(ApiError::Forbidden("Admin access required".to_string()));
505    }
506
507    let snapshot = state.metrics().snapshot();
508    Ok(snapshot.to_prometheus())
509}
510
511#[derive(OpenApi)]
512#[openapi(
513    paths(
514        health,
515        health_detailed,
516        create_agent,
517        execute_agent,
518        get_job_status,
519        get_metrics,
520        get_prometheus_metrics,
521        get_routing_stats,
522        update_routing_config,
523        crate::a2a::handler::agent_card_handler,
524        crate::a2a::handler::create_task_handler,
525        crate::a2a::handler::get_task_handler,
526    ),
527    components(
528        schemas(
529            HealthResponse, ComponentHealth, ComponentStatus,
530            CreateAgentRequest, AgentResponse,
531            ExecuteRequest, ExecuteResponse,
532            JobStatusResponse,
533            MetricsResponse,
534            RoutingStatsResponse,
535            UpdateRoutingConfigRequest,
536            crate::a2a::agent_card::AgentCard,
537            crate::a2a::agent_card::AuthConfig,
538            crate::a2a::agent_card::Skill,
539            crate::a2a::task::TaskRequest,
540            crate::a2a::task::TaskResponse,
541            crate::a2a::task::TaskStatus,
542        )
543    ),
544    modifiers(&SecurityAddon)
545)]
546pub struct ApiDoc;
547
548struct SecurityAddon;
549
550impl utoipa::Modify for SecurityAddon {
551    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
552        if let Some(components) = openapi.components.as_mut() {
553            components.add_security_scheme(
554                "jwt",
555                utoipa::openapi::security::SecurityScheme::Http(
556                    utoipa::openapi::security::HttpBuilder::new()
557                        .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
558                        .bearer_format("JWT")
559                        .build(),
560                ),
561            )
562        }
563    }
564}
565
566/// Build the API router
567pub fn api_router(state: AppState) -> Router {
568    use utoipa_swagger_ui::SwaggerUi;
569
570    Router::new()
571        // Documentation endpoints
572        .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
573        // A2A Protocol endpoints
574        .merge(crate::a2a::handler::a2a_routes().with_state(state.a2a_state()))
575        // Public endpoints
576        .route("/health", get(health))
577        .route("/health/detailed", get(health_detailed))
578        // Agent endpoints
579        .route("/api/v1/agents", post(create_agent))
580        .route("/api/v1/agents/{id}/execute", post(execute_agent))
581        // Job polling endpoint
582        .route("/api/v1/jobs/{id}", get(get_job_status))
583        // Admin endpoints
584        .route("/api/v1/metrics", get(get_metrics))
585        .route("/api/v1/routing/stats", get(get_routing_stats))
586        .route(
587            "/api/v1/routing/config",
588            axum::routing::put(update_routing_config),
589        )
590        .route("/metrics", get(get_prometheus_metrics))
591        // State
592        .with_state(state)
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn test_health_response() {
601        let health = HealthResponse {
602            status: "healthy".to_string(),
603            version: "0.1.0".to_string(),
604            timestamp: chrono::Utc::now(),
605            components: None,
606        };
607        assert_eq!(health.status, "healthy");
608    }
609}