Skip to main content

apalis_board_api/framework/
axum.rs

1use apalis_board_types::ApiError;
2use apalis_core::{
3    backend::{
4        Backend, BackendConfig, FetchById, Filter, ListAllTasks, ListQueues, ListTasks,
5        ListWorkers, Metrics, QueueInfo, RunningWorker, Statistic, TaskSink, WireFormatBackend,
6        codec::Codec, queue::Queue,
7    },
8    task::{Task, builder::TaskBuilder, metadata::MetadataStore},
9};
10use axum::{
11    Extension, Json, Router,
12    body::Body,
13    extract::{FromRequest, Path, Query, rejection::JsonRejection},
14    http::{HeaderMap, Request, StatusCode},
15    response::{IntoResponse, Response},
16    routing::{get, put},
17};
18
19use serde::{Serialize, de::DeserializeOwned};
20use std::{
21    fmt::Display,
22    str::FromStr,
23    sync::Arc,
24    time::{SystemTime, UNIX_EPOCH},
25};
26use tokio::sync::RwLock;
27
28use crate::{
29    META_PREFIX, RawTask,
30    framework::{ApiBuilder, RegisterRoute, TaskRejection},
31};
32
33/// An enumeration of possible application errors.
34#[derive(Debug, thiserror::Error)]
35pub enum AppError {
36    /// The request body contained invalid JSON
37    #[error("JSON Rejection: {0}")]
38    JsonRejection(JsonRejection),
39
40    /// An error occurred in the API
41    #[error("API Error: {0}")]
42    ApiError(ApiError),
43    /// Resource not found
44    #[error("Resource not found")]
45    NotFound,
46
47    /// Missing application state
48    #[error("Missing application state")]
49    MissingState,
50}
51
52impl IntoResponse for AppError {
53    fn into_response(self) -> Response {
54        match self {
55            Self::JsonRejection(rejection) => {
56                // This error is caused by bad user input so don't log it
57                (rejection.status(), rejection.body_text()).into_response()
58            }
59            Self::ApiError(err) => {
60                // These errors are unexpected and should be logged
61                (StatusCode::INTERNAL_SERVER_ERROR, Json(err).into_response()).into_response()
62            }
63            Self::NotFound => (StatusCode::NOT_FOUND, ()).into_response(),
64            Self::MissingState => (
65                StatusCode::INTERNAL_SERVER_ERROR,
66                "Missing application state",
67            )
68                .into_response(),
69        }
70    }
71}
72
73/// Type alias for application state extension.
74pub type State<B> = Extension<Arc<RwLock<B>>>;
75
76/// Fetch all tasks from the backend storage.
77pub async fn get_tasks<S, T>(
78    query: Query<Filter>,
79    storage: State<S>,
80) -> Result<Json<Vec<RawTask<T>>>, AppError>
81where
82    S: ListTasks + Send + 'static,
83    <S as Backend>::Error: std::error::Error + 'static,
84    S::Compact: Serialize + 'static,
85    S::Codec: Codec<T, Compact = S::Compact>,
86    T: Serialize,
87    <S::Codec as Codec<T>>::Error: std::error::Error + 'static,
88{
89    let storage = storage.0;
90    let filter = query.0;
91
92    crate::get_tasks::<S, T>(storage, filter)
93        .await
94        .map(|tasks| Json(tasks.into_iter().map(RawTask).collect()))
95        .map_err(AppError::ApiError)
96}
97
98/// Fetch statistics for a specific queue from the backend storage.
99pub async fn stats_by_queue<S>(storage: State<S>) -> Result<Json<Vec<Statistic>>, AppError>
100where
101    S::Error: std::error::Error,
102    S: Metrics,
103{
104    let storage = storage.0;
105
106    match crate::stats_by_queue::<S>(storage).await {
107        Ok(stats) => Ok(Json(stats)),
108        Err(e) => Err(AppError::ApiError(e)),
109    }
110}
111
112/// Fetch all workers from the backend storage.
113pub async fn get_workers<S>(storage: State<S>) -> Result<Json<Vec<RunningWorker>>, AppError>
114where
115    S: ListWorkers,
116    S::Error: std::error::Error,
117{
118    let storage = storage.0;
119
120    match crate::get_workers::<S>(storage).await {
121        Ok(workers) => Ok(Json(workers)),
122        Err(e) => Err(AppError::ApiError(e)),
123    }
124}
125
126/// Push a new task to the backend storage.
127pub async fn push_task<S, T>(storage: State<S>, task: RawTask<T>) -> Result<Json<()>, AppError>
128where
129    S: BackendConfig,
130    T: Serialize + DeserializeOwned + 'static + Send,
131    S: TaskSink<T, S::Kind> + 'static + Send,
132    S::Error: std::error::Error,
133{
134    match crate::push_task(task, storage.0).await {
135        Ok(_) => Ok(Json(())),
136        Err(e) => Err(AppError::ApiError(e)),
137    }
138}
139
140/// Fetch a task by its ID from the backend storage.
141pub async fn get_task_by_id<S, T>(
142    Path(task_id): Path<String>,
143    storage: State<S>,
144) -> Result<Json<RawTask<T>>, AppError>
145where
146    S: FetchById + WireFormatBackend + Backend<Task = Task<S::Compact>> + Send + 'static,
147    S::Error: std::error::Error,
148    T: Serialize + 'static + Send,
149    S::Codec: Codec<T, Compact = S::Compact>,
150    <S::Codec as Codec<T>>::Error: std::error::Error,
151{
152    let task_id = task_id.clone();
153    let storage = storage.0;
154
155    match crate::get_task_by_id::<S, T>(task_id, storage).await {
156        Ok(Some(task)) => Ok(Json(RawTask(task))),
157        Ok(None) => Err(AppError::NotFound),
158        Err(e) => Err(AppError::ApiError(e)),
159    }
160}
161
162/// Fetch all tasks from the backend storage.
163pub async fn get_all_tasks<S>(
164    query: Query<Filter>,
165    storage: State<S>,
166) -> Result<Json<Vec<RawTask<S::Compact>>>, AppError>
167where
168    S: ListAllTasks + Send + 'static,
169    S::Compact: Serialize,
170{
171    let storage = storage.0;
172    let filter = query.0;
173
174    match crate::get_all_tasks::<S>(storage, filter).await {
175        Ok(tasks) => Ok(Json(tasks.into_iter().map(RawTask).collect())),
176        Err(e) => Err(AppError::ApiError(e)),
177    }
178}
179
180/// Fetch all workers from the backend storage.
181pub async fn get_all_workers<S>(storage: State<S>) -> Result<Json<Vec<RunningWorker>>, AppError>
182where
183    S: ListWorkers + 'static,
184    S::Error: std::error::Error,
185{
186    let storage = storage.0;
187
188    match crate::get_all_workers::<S>(storage).await {
189        Ok(workers) => Ok(Json(workers)),
190        Err(e) => Err(AppError::ApiError(e)),
191    }
192}
193
194/// Fetch all queues from the backend storage.
195pub async fn fetch_queues<S>(storage: State<S>) -> Result<Json<Vec<QueueInfo>>, AppError>
196where
197    S::Error: std::error::Error,
198    S: ListQueues,
199{
200    let storage = storage.0;
201
202    crate::fetch_queues::<S>(storage)
203        .await
204        .map_err(AppError::ApiError)
205        .map(Json)
206}
207
208/// Get an overview of statistics across all queues.
209pub async fn overview<S>(storage: State<S>) -> Result<Json<Vec<Statistic>>, AppError>
210where
211    S::Error: std::error::Error,
212    S: Metrics + 'static,
213{
214    let storage = storage.0;
215
216    let tasks = crate::overview::<S>(storage)
217        .await
218        .map_err(AppError::ApiError)?;
219
220    Ok(Json(tasks))
221}
222
223impl<B, T, Compact> RegisterRoute<B, T> for ApiBuilder<Router>
224where
225    B: Metrics + ListWorkers + ListAllTasks + ListQueues + BackendConfig + Send + 'static,
226    <B as Backend>::Error: std::error::Error,
227    Compact: Serialize + 'static,
228    B::Compact: Serialize,
229    <B as Backend>::Error: std::error::Error,
230    T: Serialize + DeserializeOwned + Send + 'static,
231    B: ListTasks + FetchById,
232    B::Codec: Codec<T, Compact = Compact>,
233    B: TaskSink<T, B::Kind> + Send + Sync + 'static,
234    B: WireFormatBackend<Compact = Compact> + Backend<Task = Task<Compact>>,
235    <B::Codec as Codec<T>>::Error: std::error::Error,
236{
237    fn register(mut self, queue: &str, backend: B) -> Self {
238        let queue = Queue::from_str(queue).unwrap();
239        let backend = Arc::new(RwLock::new(backend));
240        if self.root {
241            #[allow(unused_mut)]
242            let mut r = self
243                .router
244                .route("/queues", get(fetch_queues::<B>))
245                .route("/tasks", get(get_all_tasks::<B>))
246                .route("/workers", get(get_all_workers::<B>))
247                .route("/overview", get(overview::<B>));
248
249            #[cfg(feature = "sse")]
250            {
251                r = r.route("/events", get(sse::new_client));
252            }
253
254            self.router = r.layer(Extension(backend.clone()));
255        }
256        let scope = self.router.nest(
257            &format!("/queues/{queue}"),
258            Router::new()
259                .route("/tasks", get(get_tasks::<B, T>))
260                .route("/stats", get(stats_by_queue::<B>))
261                .route("/workers", get(get_workers::<B>))
262                .route("/tasks", put(push_task::<B, T>))
263                .route("/tasks/{task_id}", get(get_task_by_id::<B, T>))
264                .layer(Extension(queue))
265                .layer(Extension(backend)),
266        );
267
268        Self {
269            router: scope,
270            root: false,
271        }
272    }
273}
274
275#[cfg(feature = "ui")]
276mod ui {
277    use std::{
278        convert::Infallible,
279        task::{Context, Poll},
280    };
281
282    use apalis_core::layers::Service;
283    use axum::{
284        body::Body,
285        http::{Request, Response, StatusCode},
286    };
287
288    use crate::ui::ServeUI;
289
290    impl Service<Request<Body>> for ServeUI {
291        type Response = Response<Body>;
292        type Error = Infallible;
293        type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
294
295        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
296            Poll::Ready(Ok(()))
297        }
298
299        fn call(&mut self, req: Request<Body>) -> Self::Future {
300            let path = req.uri().path();
301            let mut file = Self::get_file(path);
302
303            // If no matching file, fall back to index.html
304            if file.is_none() {
305                file = Self::get_file("index.html");
306            }
307
308            let response = match file {
309                Some(file) => {
310                    let path_str = file.path().to_str().unwrap_or("");
311                    let content_type = Self::content_type(path_str);
312                    let mut builder = Response::builder()
313                        .status(StatusCode::OK)
314                        .header("Content-Type", content_type);
315
316                    if let Some(cache) = Self::cache_control(path_str) {
317                        builder = builder.header("Cache-Control", cache);
318                    }
319
320                    builder.body(file.contents().to_vec().into()).unwrap()
321                }
322                None => Response::builder()
323                    .status(StatusCode::NOT_FOUND)
324                    .body(Vec::new().into())
325                    .unwrap(),
326            };
327
328            std::future::ready(Ok(response))
329        }
330    }
331}
332
333/// Expose Server-Sent Events (SSE) functionality.
334#[cfg(feature = "sse")]
335pub mod sse {
336
337    use std::{sync::Mutex, time::Duration};
338
339    use axum::response::{Sse, sse::Event};
340    use futures::{Stream, StreamExt, channel::mpsc::TryRecvError};
341
342    use crate::sse::TracingBroadcaster;
343
344    use super::*;
345
346    /// Create a new SSE client and register it with the broadcaster.
347    pub async fn new_client(
348        broadcaster: Extension<Arc<Mutex<TracingBroadcaster>>>,
349    ) -> Sse<impl Stream<Item = Result<Event, TryRecvError>>> {
350        let rx = broadcaster.lock().unwrap().new_client();
351        let stream = rx
352            .filter(|s| futures::future::ready(s.as_ref().is_ok_and(|e| e.span.is_some())))
353            .map(|entry| Ok(Event::default().json_data(entry?).unwrap()));
354
355        Sse::new(stream).keep_alive(
356            axum::response::sse::KeepAlive::new()
357                .interval(Duration::from_secs(1))
358                .text("keep-alive-text"),
359        )
360    }
361}
362
363impl<S, T> FromRequest<S> for RawTask<T>
364where
365    S: Send + Sync,
366    T: DeserializeOwned + 'static,
367{
368    type Rejection = (StatusCode, String);
369
370    async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
371        let task = extract_task(req.headers())
372            .map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()))?;
373
374        let Json(args) = Json::<T>::from_request(req, state)
375            .await
376            .map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()))?;
377
378        Ok(Self(task.map_args(|_| args)))
379    }
380}
381
382fn extract_task(headers: &HeaderMap) -> Result<Task<()>, TaskRejection> {
383    let mut task = TaskBuilder::new(());
384
385    if let Some(id) = parse_header(headers, "x-task-id")? {
386        task = task.task_id(id);
387    }
388
389    if let Some(attempt) = parse_header::<usize>(headers, "x-task-attempt")? {
390        task = task.attempt(attempt);
391    }
392
393    if let Some(status) = parse_header(headers, "x-task-status")? {
394        task = task.status(status);
395    }
396
397    if let Some(run_at) = parse_header(headers, "x-task-run-at")? {
398        task = task.run_at_timestamp(run_at);
399    } else {
400        let now = SystemTime::now()
401            .duration_since(UNIX_EPOCH)
402            .expect("Time went backwards");
403        task = task.run_at_timestamp(now.as_secs())
404    }
405
406    task = task.done_at(parse_header(headers, "x-task-done-at")?);
407
408    task = task.lock_at(parse_header(headers, "x-task-lock-at")?);
409
410    task = task.lock_by(parse_header(headers, "x-task-lock-by")?);
411
412    if let Some(idempotency_key) = parse_header::<String>(headers, "x-task-idempotency-key")? {
413        task = task.idempotency_key(idempotency_key);
414    }
415
416    if let Some(max_attempts) = parse_header(headers, "x-task-max-attempts")? {
417        task = task.max_attempts(max_attempts);
418    }
419
420    if let Some(priority) = parse_header(headers, "x-task-priority")? {
421        task = task.priority(priority);
422    }
423
424    let mut metadata = MetadataStore::default();
425
426    for (name, value) in headers.iter() {
427        if let Some(key) = name.as_str().strip_prefix(META_PREFIX) {
428            let value = value.to_str().map_err(|e| TaskRejection::BadHeader {
429                name: name.to_string(),
430                reason: e.to_string(),
431            })?;
432
433            metadata.insert(key.to_owned(), value.to_owned()).unwrap();
434        }
435    }
436
437    task = task.with_metadata(metadata);
438
439    Ok(task.build())
440}
441
442fn parse_header<T>(headers: &HeaderMap, name: &str) -> Result<Option<T>, TaskRejection>
443where
444    T: FromStr,
445    T::Err: Display,
446{
447    let Some(raw) = headers.get(name) else {
448        return Ok(None);
449    };
450    let bad = |reason: String| TaskRejection::BadHeader {
451        name: name.into(),
452        reason,
453    };
454    let s = raw.to_str().map_err(|e| bad(e.to_string()))?;
455    s.parse::<T>().map(Some).map_err(|e| bad(e.to_string()))
456}