Skip to main content

faucet_cli/serve/handlers/
runs.rs

1//! `/v1/runs*` HTTP handlers. Thin glue: deserialize, call into `runner`/history,
2//! map to status codes. All run-mutating logic lives in `runner.rs`.
3
4use crate::serve::error::ServeError;
5use crate::serve::history::{DeleteOutcome, ListFilter, RunRecord, RunStatus};
6use crate::serve::runner::{self, SubmitRequest, SubmitResponse};
7use crate::serve::state::ServerState;
8use axum::Json;
9use axum::extract::{Path, Query, State};
10use axum::http::StatusCode;
11use axum::response::IntoResponse;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15/// Scrub any resolved secret that reached a run's error fields before the record
16/// is serialized into an HTTP response. The serve log subscriber's redaction
17/// writer only covers tracing/log output — API response bodies are a separate
18/// egress and must be scrubbed here.
19fn redact_record(rec: &mut RunRecord) {
20    if let Some(e) = &rec.error {
21        rec.error = Some(crate::secrets::registry::redact(e).into_owned());
22    }
23    for inv in &mut rec.invocations {
24        if let Some(e) = &inv.error {
25            inv.error = Some(crate::secrets::registry::redact(e).into_owned());
26        }
27    }
28}
29
30/// `POST /v1/runs` → 202.
31pub async fn submit_run(
32    State(state): State<ServerState>,
33    Json(req): Json<SubmitRequest>,
34) -> Result<(StatusCode, Json<SubmitResponse>), ServeError> {
35    let resp = runner::submit(state, req).await?;
36    Ok((StatusCode::ACCEPTED, Json(resp)))
37}
38
39/// `GET /v1/runs/{id}` → 200 RunRecord. Fills live `elapsed_secs` for running runs.
40pub async fn get_run(
41    State(state): State<ServerState>,
42    Path(id): Path<String>,
43) -> Result<Json<RunRecord>, ServeError> {
44    let mut rec = state
45        .history()
46        .get(&id)
47        .await
48        .map_err(|e| ServeError::Internal(e.to_string()))?
49        .ok_or(ServeError::NotFound)?;
50    if rec.status == RunStatus::Running
51        && let Some(started) = rec.started_at
52    {
53        rec.elapsed_secs = (Utc::now() - started)
54            .to_std()
55            .ok()
56            .map(|d| d.as_secs_f64());
57    }
58    redact_record(&mut rec);
59    Ok(Json(rec))
60}
61
62/// `POST /v1/runs/{id}/cancel` → 202 (in-flight) / 200 (terminal no-op) / 404.
63pub async fn cancel_run(
64    State(state): State<ServerState>,
65    Path(id): Path<String>,
66) -> Result<impl IntoResponse, ServeError> {
67    // A live (queued/running) token → request cancellation.
68    if state.registry().cancel(&id) {
69        return Ok(StatusCode::ACCEPTED);
70    }
71    // Otherwise: terminal no-op if the record exists, else 404.
72    match state
73        .history()
74        .get(&id)
75        .await
76        .map_err(|e| ServeError::Internal(e.to_string()))?
77    {
78        Some(_) => Ok(StatusCode::OK),
79        None => Err(ServeError::NotFound),
80    }
81}
82
83/// `DELETE /v1/runs/{id}` → 204 / 404 / 409 (still running).
84pub async fn delete_run(
85    State(state): State<ServerState>,
86    Path(id): Path<String>,
87) -> Result<StatusCode, ServeError> {
88    match state
89        .history()
90        .delete(&id)
91        .await
92        .map_err(|e| ServeError::Internal(e.to_string()))?
93    {
94        DeleteOutcome::Deleted => Ok(StatusCode::NO_CONTENT),
95        DeleteOutcome::NotFound => Err(ServeError::NotFound),
96        DeleteOutcome::StillRunning => Err(ServeError::Conflict(
97            "run is still in flight — cancel it before deleting".into(),
98        )),
99    }
100}
101
102/// Query-param wrapper for an RFC3339 timestamp. `application/x-www-form-urlencoded`
103/// decodes `+` as a space, which corrupts explicit UTC offsets like `+05:30`; we
104/// restore the `+` before parsing, so both `…Z` and `…+05:30` query values work.
105#[derive(Debug, Clone, Copy)]
106pub(crate) struct DateTimeUtcParam(DateTime<Utc>);
107
108impl<'de> Deserialize<'de> for DateTimeUtcParam {
109    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
110        let raw = String::deserialize(d)?;
111        // Undo form-encoding's '+' → ' ' substitution before RFC3339 parsing.
112        let restored = raw.replace(' ', "+");
113        DateTime::parse_from_rfc3339(&restored)
114            .map(|dt| DateTimeUtcParam(dt.to_utc()))
115            .map_err(serde::de::Error::custom)
116    }
117}
118
119/// `GET /v1/runs` query string.
120#[derive(Debug, Deserialize)]
121pub struct ListQuery {
122    pub status: Option<RunStatus>,
123    pub name: Option<String>,
124    pub(crate) since: Option<DateTimeUtcParam>,
125    pub(crate) until: Option<DateTimeUtcParam>,
126    pub limit: Option<usize>,
127    pub cursor: Option<String>,
128}
129
130/// `GET /v1/runs` response body.
131#[derive(Debug, Serialize)]
132pub struct ListResponse {
133    pub runs: Vec<RunRecord>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub next_cursor: Option<String>,
136}
137
138const DEFAULT_LIMIT: usize = 50;
139const MAX_LIMIT: usize = 500;
140
141impl ListQuery {
142    fn into_filter(self) -> ListFilter {
143        ListFilter {
144            status: self.status,
145            name: self.name,
146            since: self.since.map(|p| p.0),
147            until: self.until.map(|p| p.0),
148            limit: self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT),
149            cursor: self.cursor,
150        }
151    }
152}
153
154/// `GET /v1/runs` → 200.
155pub async fn list_runs(
156    State(state): State<ServerState>,
157    Query(query): Query<ListQuery>,
158) -> Result<Json<ListResponse>, ServeError> {
159    let page = state
160        .history()
161        .list(&query.into_filter())
162        .await
163        .map_err(|e| ServeError::Internal(e.to_string()))?;
164    let mut runs = page.runs;
165    for rec in &mut runs {
166        redact_record(rec);
167    }
168    Ok(Json(ListResponse {
169        runs,
170        next_cursor: page.next_cursor,
171    }))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn datetime_param_restores_form_encoded_plus() {
180        // form-encoding turns the '+' of an offset into a space; the wrapper must
181        // restore it so both offset and Z forms parse to the same UTC instant.
182        let spaced: DateTimeUtcParam =
183            serde_json::from_value(serde_json::json!("2026-01-01T00:00:00 05:30")).unwrap();
184        let plus = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00+05:30")
185            .unwrap()
186            .to_utc();
187        assert_eq!(spaced.0, plus);
188
189        let zulu: DateTimeUtcParam =
190            serde_json::from_value(serde_json::json!("2026-01-01T00:00:00Z")).unwrap();
191        assert_eq!(
192            zulu.0,
193            chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
194                .unwrap()
195                .to_utc()
196        );
197    }
198
199    #[test]
200    fn list_query_clamps_limit() {
201        let q = ListQuery {
202            status: None,
203            name: None,
204            since: None,
205            until: None,
206            limit: Some(99999),
207            cursor: None,
208        };
209        assert_eq!(q.into_filter().limit, MAX_LIMIT);
210        let q = ListQuery {
211            status: Some(RunStatus::Failed),
212            name: None,
213            since: None,
214            until: None,
215            limit: None,
216            cursor: None,
217        };
218        let f = q.into_filter();
219        assert_eq!(f.limit, DEFAULT_LIMIT);
220        assert_eq!(f.status, Some(RunStatus::Failed));
221    }
222}