Skip to main content

apalis_board_api/framework/
actix.rs

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