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#[derive(Debug, thiserror::Error)]
35pub enum AppError {
36 #[error("JSON Rejection: {0}")]
38 JsonRejection(JsonRejection),
39
40 #[error("API Error: {0}")]
42 ApiError(ApiError),
43 #[error("Resource not found")]
45 NotFound,
46
47 #[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 (rejection.status(), rejection.body_text()).into_response()
58 }
59 Self::ApiError(err) => {
60 (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
73pub type State<B> = Extension<Arc<RwLock<B>>>;
75
76pub 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
98pub 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
112pub 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
126pub 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
140pub 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
162pub 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
180pub 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
194pub 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
208pub 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 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#[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 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}