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#[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 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 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 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 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 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 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 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 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 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)) .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 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#[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 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}