1use 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};
47pub(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
101pub struct HttpApi<H> {
108 handler: Arc<H>,
109 auth: Option<Token>,
110}
111
112impl<H> HttpApi<H>
113where
114 H: ApiHandler,
115{
116 pub fn new(handler: Arc<H>) -> Self {
118 Self {
119 handler,
120 auth: None,
121 }
122 }
123
124 pub fn with_auth(mut self, token: Token) -> Self {
130 self.auth = Some(token);
131 self
132 }
133
134 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 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
159async 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
177fn 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
317async 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}