Skip to main content

solti_api/
http.rs

1//! # HTTP/JSON transport.
2//!
3//! Axum router exposing [`ApiHandler`] operations as REST-shaped JSON endpoints.
4//! All paths share the `/api/v<MAJOR>` prefix where `MAJOR` is [`crate::API_VERSION`];
5//!
6//! _the examples below show the current value (`v1`)_.
7//!
8//! | Method | Endpoint                    | Handler                   |
9//! |--------|-----------------------------|---------------------------|
10//! | POST   | `/api/v1/tasks`             | submit                    |
11//! | PUT    | `/api/v1/tasks`             | apply (supersede/install) |
12//! | GET    | `/api/v1/tasks`             | list (query params)       |
13//! | GET    | `/api/v1/tasks/{id}`        | get status                |
14//! | GET    | `/api/v1/tasks/{id}/runs`   | list runs                 |
15//! | GET    | `/api/v1/tasks/{id}/logs`   | live-tail SSE stream      |
16//! | DELETE | `/api/v1/tasks/{id}`        | delete (stop+purge)       |
17
18use std::sync::Arc;
19
20use std::convert::Infallible;
21
22use axum::{
23    Json, Router,
24    extract::{FromRequest, Path, Query, Request, State, rejection::JsonRejection},
25    http::StatusCode,
26    middleware::{self, Next},
27    response::{
28        IntoResponse, Response,
29        sse::{Event, KeepAlive, Sse},
30    },
31    routing::{delete, get, post, put},
32};
33use serde::{Deserialize, de::DeserializeOwned};
34use solti_model::{OutputEvent, TaskId, TaskPhase, TaskQuery, Token};
35use tokio_stream::StreamExt;
36use tower_http::limit::RequestBodyLimitLayer;
37use tracing::debug;
38
39use crate::{
40    MAX_REQUEST_BYTES,
41    convert::{self, tasks_page_to_proto},
42    error::ApiError,
43    handler::ApiHandler,
44    proto_api,
45    validate::{clamp_list_limit, non_empty_id},
46};
47// `api_url!` is `#[macro_export]`, so it's already accessible in this
48// module by its bare name — `use crate::api_url` would be redundant
49// (and warnings about unused imports broke a `cargo publish` on us).
50
51/// Wrapper around `axum::Json<T>` that maps `JsonRejection` into [`ApiError::InvalidRequest`].
52pub(crate) struct ApiJson<T>(pub T);
53
54impl<T, S> FromRequest<S> for ApiJson<T>
55where
56    T: DeserializeOwned,
57    S: Send + Sync,
58{
59    type Rejection = ApiError;
60
61    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
62        let Json(value) = axum::Json::<T>::from_request(req, state)
63            .await
64            .map_err(map_json_rejection)?;
65        Ok(ApiJson(value))
66    }
67}
68
69fn map_json_rejection(rej: JsonRejection) -> ApiError {
70    if rej.status() == StatusCode::PAYLOAD_TOO_LARGE {
71        return ApiError::PayloadTooLarge(format!(
72            "request body exceeds the maximum of {} bytes",
73            MAX_REQUEST_BYTES
74        ));
75    }
76
77    let msg = rej.body_text();
78    let trimmed = msg
79        .strip_prefix("Failed to deserialize the JSON body into the target type: ")
80        .or_else(|| msg.strip_prefix("Failed to parse the request body as JSON: "))
81        .unwrap_or(&msg)
82        .to_string();
83    ApiError::InvalidRequest(trimmed)
84}
85
86async fn map_413_envelope(req: Request, next: Next) -> Response {
87    let resp = next.run(req).await;
88    if resp.status() == StatusCode::PAYLOAD_TOO_LARGE {
89        let body = serde_json::json!({
90            "error": "PayloadTooLarge",
91            "message": format!(
92                "request body exceeds the maximum of {} bytes",
93                MAX_REQUEST_BYTES
94            ),
95        });
96        return (StatusCode::PAYLOAD_TOO_LARGE, Json(body)).into_response();
97    }
98    resp
99}
100
101/// HTTP API service builder.
102///
103/// ## Also
104///
105/// - [`ApiHandler`](crate::ApiHandler) the trait backing all endpoints.
106/// - [`ApiError`](crate::ApiError) mapped to JSON + HTTP status codes.
107pub struct HttpApi<H> {
108    handler: Arc<H>,
109    auth: Option<Token>,
110}
111
112impl<H> HttpApi<H>
113where
114    H: ApiHandler,
115{
116    /// Create new HTTP API with the given handler.
117    pub fn new(handler: Arc<H>) -> Self {
118        Self {
119            handler,
120            auth: None,
121        }
122    }
123
124    /// Require a bearer token on every request.
125    ///
126    /// When set, requests without a valid `Authorization: Bearer <token>` header are rejected with `401 Unauthorized` before reaching any handler .
127    /// This is the same shared secret the agent presents to the control plane in discovery, one config value enables both directions.
128    /// Orthogonal to TLS. When unset, no auth is enforced.
129    pub fn with_auth(mut self, token: Token) -> Self {
130        self.auth = Some(token);
131        self
132    }
133
134    /// Build axum router with mounted endpoints.
135    ///
136    /// Applies a [`RequestBodyLimitLayer`] capped at [`MAX_REQUEST_BYTES`] bytes to every request,
137    /// and when [`with_auth`](Self::with_auth) is set a bearer-token gate that runs before any handler.
138    pub fn router(self) -> Router {
139        let mut router = Router::new()
140            .route(api_url!("/tasks"), post(submit_task::<H>))
141            .route(api_url!("/tasks"), put(apply_task::<H>))
142            .route(api_url!("/tasks"), get(list_tasks::<H>))
143            .route(api_url!("/tasks/{id}"), get(get_task_status::<H>))
144            .route(api_url!("/tasks/{id}"), delete(delete_task::<H>))
145            .route(api_url!("/tasks/{id}/runs"), get(list_task_runs::<H>))
146            .route(api_url!("/tasks/{id}/logs"), get(stream_task_logs::<H>))
147            .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES))
148            .layer(middleware::from_fn(map_413_envelope));
149
150        // Added last → outermost → runs first: reject unauthenticated requests before any work happens.
151        if let Some(token) = self.auth {
152            router = router.layer(middleware::from_fn_with_state(token, require_bearer));
153        }
154
155        router.with_state(self.handler)
156    }
157}
158
159/// Axum middleware: reject requests lacking a valid `Authorization: Bearer` token.
160/// Installed only when [`HttpApi::with_auth`] is set.
161async fn require_bearer(State(expected): State<Token>, req: Request, next: Next) -> Response {
162    let ok = req
163        .headers()
164        .get(axum::http::header::AUTHORIZATION)
165        .and_then(|v| v.to_str().ok())
166        .and_then(bearer_value)
167        .map(|presented| expected.verify(presented))
168        .unwrap_or(false);
169
170    if ok {
171        next.run(req).await
172    } else {
173        ApiError::Unauthenticated("missing or invalid bearer token".into()).into_response()
174    }
175}
176
177/// Extract the credential from an `Authorization` header value, accepting the scheme case-insensitively.
178fn bearer_value(header: &str) -> Option<&str> {
179    let (scheme, token) = header.split_once(' ')?;
180    scheme.eq_ignore_ascii_case("bearer").then_some(token)
181}
182
183#[derive(Debug, Deserialize)]
184struct ListTasksParams {
185    slot: Option<String>,
186    status: Option<String>,
187    limit: Option<u32>,
188    offset: Option<u32>,
189}
190
191async fn submit_task<H>(
192    State(handler): State<Arc<H>>,
193    ApiJson(req): ApiJson<proto_api::SubmitTaskRequest>,
194) -> Result<impl IntoResponse, ApiError>
195where
196    H: ApiHandler,
197{
198    let spec = req
199        .spec
200        .ok_or_else(|| ApiError::InvalidRequest("missing spec".into()))?;
201    let spec = convert::convert_create_spec(spec)?;
202
203    debug!(slot = %spec.slot(), kind = ?spec.kind(), "submitting task");
204    let task_id = handler.submit_task(spec).await?;
205
206    let response = proto_api::SubmitTaskResponse {
207        task_id: task_id.to_string(),
208    };
209    Ok((StatusCode::CREATED, Json(response)))
210}
211
212async fn apply_task<H>(
213    State(handler): State<Arc<H>>,
214    ApiJson(req): ApiJson<proto_api::ApplyTaskRequest>,
215) -> Result<impl IntoResponse, ApiError>
216where
217    H: ApiHandler,
218{
219    let spec = req
220        .spec
221        .ok_or_else(|| ApiError::InvalidRequest("missing spec".into()))?;
222    let spec = convert::convert_create_spec(spec)?;
223
224    debug!(slot = %spec.slot(), kind = ?spec.kind(), "applying task");
225    let task_id = handler.apply_task(spec).await?;
226
227    let response = proto_api::ApplyTaskResponse {
228        task_id: task_id.to_string(),
229    };
230    Ok((StatusCode::OK, Json(response)))
231}
232
233async fn get_task_status<H>(
234    State(handler): State<Arc<H>>,
235    Path(id): Path<String>,
236) -> Result<impl IntoResponse, ApiError>
237where
238    H: ApiHandler,
239{
240    non_empty_id("task_id", &id)?;
241
242    let task_id = TaskId::from(id);
243    debug!(%task_id, "getting task status");
244    let task = handler.get_task_status(&task_id).await?;
245
246    let task = task.map(proto_api::TaskData::try_from).transpose()?;
247    Ok(Json(proto_api::GetTaskStatusResponse { task }))
248}
249
250async fn list_tasks<H>(
251    State(handler): State<Arc<H>>,
252    Query(params): Query<ListTasksParams>,
253) -> Result<impl IntoResponse, ApiError>
254where
255    H: ApiHandler,
256{
257    let mut query = TaskQuery::new();
258
259    if let Some(slot) = params.slot {
260        non_empty_id("slot", &slot)?;
261        query = query.with_slot(slot);
262    }
263
264    if let Some(status_str) = params.status {
265        let status = status_str.parse::<TaskPhase>().map_err(|_| {
266            ApiError::InvalidRequest(format!(
267                "invalid status: '{status_str}' (valid: pending, running, succeeded, failed, timeout, canceled, exhausted)"
268            ))
269        })?;
270        query = query.with_status(status);
271    }
272
273    query = query.with_limit(clamp_list_limit(params.limit.unwrap_or(0)));
274    if let Some(offset) = params.offset {
275        query = query.with_offset(offset as usize);
276    }
277
278    let page = handler.query_tasks(query).await?;
279    debug!(count = page.items.len(), total = page.total, "tasks listed");
280
281    Ok(Json(tasks_page_to_proto(page)?))
282}
283
284async fn list_task_runs<H>(
285    State(handler): State<Arc<H>>,
286    Path(id): Path<String>,
287) -> Result<impl IntoResponse, ApiError>
288where
289    H: ApiHandler,
290{
291    non_empty_id("task_id", &id)?;
292
293    let task_id = TaskId::from(id);
294    debug!(%task_id, "listing task runs");
295    let runs = handler.list_task_runs(&task_id).await?;
296    let runs = runs.into_iter().map(proto_api::TaskRunInfo::from).collect();
297
298    Ok(Json(proto_api::ListTaskRunsResponse { runs }))
299}
300
301async fn delete_task<H>(
302    State(handler): State<Arc<H>>,
303    Path(id): Path<String>,
304) -> Result<impl IntoResponse, ApiError>
305where
306    H: ApiHandler,
307{
308    non_empty_id("task_id", &id)?;
309
310    let task_id = TaskId::from(id);
311    handler.delete_task(&task_id).await?;
312    debug!(%task_id, "task deleted");
313
314    Ok(StatusCode::NO_CONTENT)
315}
316
317/// `GET /tasks/{id}/logs` - Server-Sent Events stream of [`OutputEvent`]s (live tail of stdout/stderr + run boundary markers + lag signals).
318async fn stream_task_logs<H>(
319    State(handler): State<Arc<H>>,
320    Path(id): Path<String>,
321) -> Result<Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>>, ApiError>
322where
323    H: ApiHandler,
324{
325    non_empty_id("task_id", &id)?;
326
327    let task_id = TaskId::from(id);
328    debug!(%task_id, "subscribing to task log stream");
329    let stream = handler.stream_task_logs(&task_id).await?;
330
331    let sse_stream = stream.map(|ev| {
332        let name = match &ev {
333            OutputEvent::Chunk(_) => "chunk",
334            OutputEvent::RunStarted { .. } => "run-started",
335            OutputEvent::RunFinished { .. } => "run-finished",
336            OutputEvent::Lagged { .. } => "lagged",
337        };
338        let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".into());
339        Ok(Event::default().event(name).data(data))
340    });
341    Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
342}
343
344#[cfg(test)]
345mod tests {
346    use super::bearer_value;
347
348    #[test]
349    fn bearer_value_accepts_scheme_case_insensitively() {
350        assert_eq!(bearer_value("Bearer tok"), Some("tok"));
351        assert_eq!(bearer_value("bearer tok"), Some("tok"));
352        assert_eq!(bearer_value("BEARER tok"), Some("tok"));
353        assert_eq!(bearer_value("BeArEr tok"), Some("tok"));
354        assert_eq!(bearer_value("Bearer a b"), Some("a b"));
355        assert_eq!(bearer_value("Basic tok"), None);
356        assert_eq!(bearer_value("tok"), None);
357        assert_eq!(bearer_value(""), None);
358    }
359}