Skip to main content

ares/api/handlers/
v1.rs

1//! V1 API handlers — tenant-scoped endpoints authenticated via API key.
2//!
3//! These endpoints are called by enterprise-portal and other client apps
4//! using `Authorization: Bearer ares_xxx`. The `api_key_auth_middleware`
5//! injects `TenantContext` into request extensions before these handlers run.
6
7use crate::db::tenant_agents::{self, TenantAgent};
8use crate::db::agent_runs;
9use crate::models::TenantContext;
10use crate::types::{AppError, Result};
11use crate::AppState;
12use axum::{
13    extract::{Extension, Path, Query, State},
14    http::StatusCode,
15    Json,
16};
17use chrono::{DateTime, Datelike, TimeZone, Utc};
18use serde::{Deserialize, Serialize};
19
20// =============================================================================
21// Response types — designed to match enterprise-portal's expected types
22// =============================================================================
23
24#[derive(Debug, Serialize)]
25pub struct V1Agent {
26    pub id: String,
27    pub name: String,
28    pub agent_type: String,
29    pub status: V1AgentStatus,
30    pub config: serde_json::Value,
31    pub created_at: DateTime<Utc>,
32    pub last_run: Option<DateTime<Utc>>,
33    pub total_runs: u64,
34    pub success_rate: f64,
35}
36
37#[derive(Debug, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum V1AgentStatus {
40    Active,
41    Idle,
42    Error,
43    Disabled,
44}
45
46impl From<TenantAgent> for V1Agent {
47    fn from(a: TenantAgent) -> Self {
48        let status = if a.enabled {
49            V1AgentStatus::Active
50        } else {
51            V1AgentStatus::Disabled
52        };
53        Self {
54            id: a.id,
55            name: a.agent_name,
56            agent_type: "custom".to_string(),
57            status,
58            config: a.config,
59            created_at: ts_to_dt(a.created_at),
60            last_run: None,
61            total_runs: 0,
62            success_rate: 0.0,
63        }
64    }
65}
66
67#[derive(Debug, Serialize)]
68pub struct V1AgentRun {
69    pub id: String,
70    pub agent_id: String,
71    pub status: String,
72    pub input: serde_json::Value,
73    pub output: Option<serde_json::Value>,
74    pub error: Option<String>,
75    pub started_at: DateTime<Utc>,
76    pub finished_at: Option<DateTime<Utc>>,
77    pub duration_ms: Option<u64>,
78    pub tokens_used: Option<u64>,
79}
80
81#[derive(Debug, Serialize)]
82pub struct V1AgentLog {
83    pub id: String,
84    pub agent_id: String,
85    pub run_id: Option<String>,
86    pub level: String,
87    pub message: String,
88    pub metadata: Option<serde_json::Value>,
89    pub timestamp: DateTime<Utc>,
90}
91
92#[derive(Debug, Serialize)]
93pub struct Paginated<T> {
94    pub items: Vec<T>,
95    pub total: u64,
96    pub page: u32,
97    pub per_page: u32,
98    pub total_pages: u32,
99}
100
101impl<T> Paginated<T> {
102    fn empty(page: u32, per_page: u32) -> Self {
103        Self {
104            items: vec![],
105            total: 0,
106            page,
107            per_page,
108            total_pages: 0,
109        }
110    }
111}
112
113#[derive(Debug, Serialize)]
114pub struct V1Usage {
115    pub period_start: DateTime<Utc>,
116    pub period_end: DateTime<Utc>,
117    pub total_runs: u64,
118    pub total_tokens: u64,
119    pub total_api_calls: u64,
120    pub quota_runs: Option<u64>,
121    pub quota_tokens: Option<u64>,
122    pub daily_usage: Vec<DailyUsage>,
123}
124
125#[derive(Debug, Serialize)]
126pub struct DailyUsage {
127    pub date: String,
128    pub runs: u64,
129    pub tokens: u64,
130    pub api_calls: u64,
131}
132
133#[derive(Debug, Serialize)]
134pub struct V1ApiKey {
135    pub id: String,
136    pub name: String,
137    pub prefix: String,
138    pub created_at: DateTime<Utc>,
139    pub last_used: Option<DateTime<Utc>>,
140    pub expires_at: Option<DateTime<Utc>>,
141}
142
143#[derive(Debug, Deserialize)]
144pub struct CreateApiKeyRequest {
145    pub name: String,
146    pub expires_in_days: Option<u32>,
147}
148
149#[derive(Debug, Serialize)]
150pub struct CreateApiKeyResponse {
151    pub key: V1ApiKey,
152    pub secret: String,
153}
154
155#[derive(Debug, Deserialize)]
156pub struct PaginationQuery {
157    pub page: Option<u32>,
158    pub per_page: Option<u32>,
159}
160
161// =============================================================================
162// Helpers
163// =============================================================================
164
165fn ts_to_dt(ts: i64) -> DateTime<Utc> {
166    Utc.timestamp_opt(ts, 0).single().unwrap_or_else(Utc::now)
167}
168
169fn extract_tenant(ctx: Option<Extension<TenantContext>>) -> Result<TenantContext> {
170    ctx.map(|Extension(c)| c)
171        .ok_or_else(|| AppError::Auth("Missing tenant context".to_string()))
172}
173
174// =============================================================================
175// Handlers
176// =============================================================================
177
178/// GET /v1/agents — list all agents for this tenant
179pub async fn list_agents(
180    State(state): State<AppState>,
181    ctx: Option<Extension<TenantContext>>,
182    Query(q): Query<PaginationQuery>,
183) -> Result<Json<Paginated<V1Agent>>> {
184    let tc = extract_tenant(ctx)?;
185    let page = q.page.unwrap_or(1).max(1);
186    let per_page = q.per_page.unwrap_or(20).min(100);
187
188    let agents = tenant_agents::list_tenant_agents(state.tenant_db.pool(), &tc.tenant_id).await?;
189    let total = agents.len() as u64;
190    let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
191
192    let start = ((page - 1) * per_page) as usize;
193    let items: Vec<V1Agent> = agents
194        .into_iter()
195        .skip(start)
196        .take(per_page as usize)
197        .map(V1Agent::from)
198        .collect();
199
200    Ok(Json(Paginated {
201        items,
202        total,
203        page,
204        per_page,
205        total_pages,
206    }))
207}
208
209/// GET /v1/agents/{name} — get a specific agent
210pub async fn get_agent(
211    State(state): State<AppState>,
212    ctx: Option<Extension<TenantContext>>,
213    Path(name): Path<String>,
214) -> Result<Json<V1Agent>> {
215    let tc = extract_tenant(ctx)?;
216    let agent = tenant_agents::get_tenant_agent(state.tenant_db.pool(), &tc.tenant_id, &name).await?;
217    Ok(Json(V1Agent::from(agent)))
218}
219
220/// POST /v1/agents/{name}/run — trigger an agent run (proxies to chat)
221pub async fn run_agent(
222    State(state): State<AppState>,
223    ctx: Option<Extension<TenantContext>>,
224    Path(name): Path<String>,
225    Json(input): Json<serde_json::Value>,
226) -> Result<Json<V1AgentRun>> {
227    let tc = extract_tenant(ctx)?;
228    // Verify the agent exists for this tenant
229    let _agent = tenant_agents::get_tenant_agent(state.tenant_db.pool(), &tc.tenant_id, &name).await?;
230
231    // Return a stub run for now — actual execution would proxy through the chat handler
232    Ok(Json(V1AgentRun {
233        id: uuid::Uuid::new_v4().to_string(),
234        agent_id: name,
235        status: "completed".to_string(),
236        input,
237        output: Some(serde_json::json!({"message": "Agent run queued"})),
238        error: None,
239        started_at: Utc::now(),
240        finished_at: Some(Utc::now()),
241        duration_ms: Some(0),
242        tokens_used: Some(0),
243    }))
244}
245
246/// GET /v1/agents/{name}/runs — list runs for an agent
247pub async fn list_agent_runs(
248    State(state): State<AppState>,
249    ctx: Option<Extension<TenantContext>>,
250    Path(name): Path<String>,
251    Query(q): Query<PaginationQuery>,
252) -> Result<Json<Paginated<V1AgentRun>>> {
253    let tc = extract_tenant(ctx)?;
254    let page = q.page.unwrap_or(1).max(1);
255    let per_page = q.per_page.unwrap_or(25).min(100);
256    let offset = ((page - 1) * per_page) as i64;
257
258    let runs = agent_runs::list_agent_runs(
259        state.tenant_db.pool(),
260        &tc.tenant_id,
261        Some(&name),
262        per_page as i64,
263        offset,
264    ).await?;
265
266    let items: Vec<V1AgentRun> = runs.into_iter().map(|r| V1AgentRun {
267        id: r.id,
268        agent_id: r.agent_name,
269        status: r.status,
270        input: serde_json::json!({"tokens": r.input_tokens}),
271        output: Some(serde_json::json!({"tokens": r.output_tokens})),
272        error: r.error,
273        started_at: ts_to_dt(r.created_at),
274        finished_at: Some(ts_to_dt(r.created_at + (r.duration_ms / 1000))),
275        duration_ms: Some(r.duration_ms as u64),
276        tokens_used: Some((r.input_tokens + r.output_tokens) as u64),
277    }).collect();
278
279    let total = items.len() as u64;
280    Ok(Json(Paginated {
281        items,
282        total,
283        page,
284        per_page,
285        total_pages: ((total as f64) / (per_page as f64)).ceil() as u32,
286    }))
287}
288
289/// GET /v1/agents/{name}/logs — list logs for an agent (stub: returns empty)
290pub async fn list_agent_logs(
291    ctx: Option<Extension<TenantContext>>,
292    Path(name): Path<String>,
293    Query(q): Query<PaginationQuery>,
294) -> Result<Json<Paginated<V1AgentLog>>> {
295    let _tc = extract_tenant(ctx)?;
296    let page = q.page.unwrap_or(1);
297    let per_page = q.per_page.unwrap_or(50);
298    let _ = name;
299    Ok(Json(Paginated::empty(page, per_page)))
300}
301
302/// GET /v1/usage — get usage summary for this tenant
303pub async fn get_usage(
304    State(state): State<AppState>,
305    ctx: Option<Extension<TenantContext>>,
306) -> Result<Json<V1Usage>> {
307    let tc = extract_tenant(ctx)?;
308    let summary = state.tenant_db.get_usage_summary(&tc.tenant_id).await?;
309
310    let now = Utc::now();
311    let period_start = now
312        .date_naive()
313        .with_day(1)
314        .unwrap()
315        .and_hms_opt(0, 0, 0)
316        .unwrap()
317        .and_utc();
318
319    // Quota limits (cap u64::MAX to None for display)
320    let quota_runs = if tc.quota.requests_per_month == u64::MAX {
321        None
322    } else {
323        Some(tc.quota.requests_per_month)
324    };
325    let quota_tokens = if tc.quota.tokens_per_month == u64::MAX {
326        None
327    } else {
328        Some(tc.quota.tokens_per_month)
329    };
330
331    Ok(Json(V1Usage {
332        period_start,
333        period_end: now,
334        total_runs: summary.monthly_requests,
335        total_tokens: summary.monthly_tokens,
336        total_api_calls: summary.monthly_requests,
337        quota_runs,
338        quota_tokens,
339        daily_usage: vec![],
340    }))
341}
342
343/// GET /v1/api-keys — list API keys for this tenant
344pub async fn list_api_keys(
345    State(state): State<AppState>,
346    ctx: Option<Extension<TenantContext>>,
347) -> Result<Json<Vec<V1ApiKey>>> {
348    let tc = extract_tenant(ctx)?;
349    let keys = state.tenant_db.list_api_keys(&tc.tenant_id).await?;
350
351    let response: Vec<V1ApiKey> = keys
352        .into_iter()
353        .filter(|k| k.is_active)
354        .map(|k| V1ApiKey {
355            id: k.id,
356            name: k.name,
357            prefix: k.key_prefix,
358            created_at: ts_to_dt(k.created_at),
359            last_used: None,
360            expires_at: k.expires_at.map(|e| ts_to_dt(e)),
361        })
362        .collect();
363
364    Ok(Json(response))
365}
366
367/// POST /v1/api-keys — create a new API key
368pub async fn create_api_key(
369    State(state): State<AppState>,
370    ctx: Option<Extension<TenantContext>>,
371    Json(payload): Json<CreateApiKeyRequest>,
372) -> Result<Json<CreateApiKeyResponse>> {
373    let tc = extract_tenant(ctx)?;
374    let (api_key, raw_key) = state
375        .tenant_db
376        .create_api_key(&tc.tenant_id, payload.name)
377        .await?;
378
379    Ok(Json(CreateApiKeyResponse {
380        key: V1ApiKey {
381            id: api_key.id,
382            name: api_key.name,
383            prefix: api_key.key_prefix,
384            created_at: ts_to_dt(api_key.created_at),
385            last_used: None,
386            expires_at: api_key.expires_at.map(|e| ts_to_dt(e)),
387        },
388        secret: raw_key,
389    }))
390}
391
392/// DELETE /v1/api-keys/{id} — revoke an API key
393pub async fn revoke_api_key(
394    State(state): State<AppState>,
395    ctx: Option<Extension<TenantContext>>,
396    Path(key_id): Path<String>,
397) -> Result<StatusCode> {
398    let tc = extract_tenant(ctx)?;
399    state
400        .tenant_db
401        .revoke_api_key(&tc.tenant_id, &key_id)
402        .await?;
403    Ok(StatusCode::NO_CONTENT)
404}