Skip to main content

cronoq_server/
http.rs

1//! HTTP/JSON gateway, health checks and Prometheus metrics.
2//!
3//! The router is created once and serves for the whole process lifetime: while standing by
4//! (or shutting down) the API answers `503` with a JSON error, `/healthz` stays `200` and
5//! `/readyz` reports `503`. Requests are authenticated before their body is read.
6//!
7//! Task ids are JSON strings (they exceed the integer precision of JavaScript numbers).
8
9use std::collections::HashMap;
10use std::convert::Infallible;
11use std::sync::Arc;
12use std::time::Duration;
13
14use axum::extract::rejection::JsonRejection;
15use axum::extract::{DefaultBodyLimit, FromRequest, Path, Query, Request, State};
16use axum::http::{HeaderValue, StatusCode, header};
17use axum::middleware::{Next, from_fn_with_state};
18use axum::response::{IntoResponse, Response};
19use axum::routing::{get, post};
20use axum::{Extension, Json, Router};
21use base64::Engine;
22use base64::engine::general_purpose::STANDARD as B64;
23use cronoq::{TaskId, TaskRef, TaskStatus};
24use cronoq_proto::v1 as pb;
25use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
26use hyper_util::server::conn::auto;
27use hyper_util::service::TowerToHyperService;
28use serde::Deserialize;
29use serde::de::DeserializeOwned;
30use serde_json::{Value, json};
31use tokio::net::TcpListener;
32use tokio_util::sync::CancellationToken;
33use tower::ServiceExt;
34
35use crate::auth::{AuthError, Principal};
36use crate::config::Role;
37use crate::grpc;
38use crate::state::{AppState, Shared};
39
40/// Per-connection facts made available to handlers.
41#[derive(Clone, Debug, Default)]
42pub struct ConnInfo {
43    /// Principal mapped from the client certificate (mTLS), if any.
44    pub cert: Option<Principal>,
45}
46
47/// An error response: `{"error": "...", "code": "..."}`.
48#[derive(Debug)]
49pub struct ApiError(pub StatusCode, pub String);
50
51impl IntoResponse for ApiError {
52    fn into_response(self) -> Response {
53        let code = self
54            .0
55            .canonical_reason()
56            .unwrap_or("error")
57            .to_ascii_lowercase()
58            .replace(' ', "_");
59        (self.0, Json(json!({ "error": self.1, "code": code }))).into_response()
60    }
61}
62
63impl From<tonic::Status> for ApiError {
64    fn from(s: tonic::Status) -> Self {
65        use tonic::Code::*;
66        let code = match s.code() {
67            InvalidArgument | OutOfRange => StatusCode::BAD_REQUEST,
68            NotFound => StatusCode::NOT_FOUND,
69            AlreadyExists | FailedPrecondition | Aborted => StatusCode::CONFLICT,
70            ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
71            Unauthenticated => StatusCode::UNAUTHORIZED,
72            PermissionDenied => StatusCode::FORBIDDEN,
73            DeadlineExceeded => StatusCode::GATEWAY_TIMEOUT,
74            Unavailable => StatusCode::SERVICE_UNAVAILABLE,
75            Unimplemented => StatusCode::NOT_IMPLEMENTED,
76            _ => StatusCode::INTERNAL_SERVER_ERROR,
77        };
78        ApiError(code, s.message().to_string())
79    }
80}
81
82impl From<AuthError> for ApiError {
83    fn from(e: AuthError) -> Self {
84        let mut r = ApiError::from(e.to_status());
85        if e != AuthError::Forbidden {
86            r.0 = StatusCode::UNAUTHORIZED;
87        }
88        r
89    }
90}
91
92fn bad(m: impl Into<String>) -> ApiError {
93    ApiError(StatusCode::BAD_REQUEST, m.into())
94}
95
96fn unavailable() -> ApiError {
97    ApiError(
98        StatusCode::SERVICE_UNAVAILABLE,
99        "not the leader (standby or shutting down)".into(),
100    )
101}
102
103/// JSON body extractor whose rejections are JSON errors.
104pub struct ApiJson<T>(pub T);
105
106impl<T: DeserializeOwned, S: Send + Sync> FromRequest<S> for ApiJson<T> {
107    type Rejection = ApiError;
108
109    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
110        match Json::<T>::from_request(req, state).await {
111            Ok(Json(v)) => Ok(ApiJson(v)),
112            Err(e) => {
113                let status = match &e {
114                    JsonRejection::MissingJsonContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
115                    JsonRejection::BytesRejection(b) => b.status(),
116                    _ => StatusCode::BAD_REQUEST,
117                };
118                Err(ApiError(status, e.body_text()))
119            }
120        }
121    }
122}
123
124type St = Arc<Shared>;
125
126fn app(st: &Shared) -> Result<&Arc<AppState>, ApiError> {
127    st.app().ok_or_else(unavailable)
128}
129
130/// Authenticates (before the body is read), checks readiness, bounds the request time and
131/// hands the [`Principal`] to the handler.
132async fn gate(State((st, role)): State<(St, Role)>, mut req: Request, next: Next) -> Response {
133    if st.loopback_hosts_only && !loopback_host(&req) {
134        return ApiError(
135            StatusCode::FORBIDDEN,
136            "without auth the API only answers requests for a loopback host".into(),
137        )
138        .into_response();
139    }
140    let header = req
141        .headers()
142        .get(header::AUTHORIZATION)
143        .and_then(|v| v.to_str().ok());
144    let cert = req
145        .extensions()
146        .get::<ConnInfo>()
147        .and_then(|c| c.cert.clone());
148    let mut principal = match st.auth.authorize(header, cert.as_ref(), role) {
149        Ok(p) => p,
150        Err(e) => {
151            let mut r = ApiError::from(e).into_response();
152            if r.status() == StatusCode::UNAUTHORIZED {
153                r.headers_mut()
154                    .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
155            }
156            return r;
157        }
158    };
159    if st.app().is_none() {
160        return unavailable().into_response();
161    }
162    // Waiting routes may block for up to `max_wait_s`.
163    let waits = req.uri().path().ends_with("/result") || req.uri().path() == "/v1/tasks";
164    let limit = st.limits.request_timeout()
165        + if waits {
166            st.limits.max_wait()
167        } else {
168            Duration::ZERO
169        };
170    principal.forwarded = req.headers().contains_key(crate::auth::FORWARDED_HEADER);
171    req.extensions_mut().insert(principal);
172    match tokio::time::timeout(limit, next.run(req)).await {
173        Ok(r) => r,
174        Err(_) => ApiError(StatusCode::GATEWAY_TIMEOUT, "request timed out".into()).into_response(),
175    }
176}
177
178/// Whether the request's host (the `Host` header, or the HTTP/2 authority) is `localhost`, a
179/// `*.localhost` name or a loopback address.
180pub(crate) fn loopback_host<B>(req: &::http::Request<B>) -> bool {
181    let host = req
182        .headers()
183        .get(header::HOST)
184        .and_then(|v| v.to_str().ok())
185        .or_else(|| req.uri().authority().map(|a| a.as_str()));
186    let Some(host) = host else { return false };
187    let name = match host.strip_prefix('[') {
188        Some(v6) => match v6.split_once(']') {
189            Some((ip, _)) => ip,
190            None => return false,
191        },
192        None => host.rsplit_once(':').map_or(host, |(h, _)| h),
193    };
194    let name = name.trim_end_matches('.').to_ascii_lowercase();
195    name == "localhost"
196        || name.ends_with(".localhost")
197        || name
198            .parse::<std::net::IpAddr>()
199            .is_ok_and(|ip| ip.is_loopback())
200}
201
202fn gated(router: Router<St>, st: &St, role: Role) -> Router<St> {
203    router.route_layer(from_fn_with_state((st.clone(), role), gate))
204}
205
206/// The full HTTP router (health, API, metrics).
207pub fn router(st: St) -> Router {
208    let api = Router::new()
209        .route("/v1/tasks", post(submit).get(list_tasks))
210        .route("/v1/tasks:batch", post(submit_batch))
211        .route("/v1/tasks/{id}", get(get_by_id).delete(cancel_by_id))
212        .route("/v1/tasks/{id}/result", get(result_by_id))
213        .route(
214            "/v1/tasks/by-key/{key}",
215            get(get_by_key).delete(cancel_by_key),
216        )
217        .route("/v1/tasks/by-key/{key}/result", get(result_by_key));
218    let metrics = Router::new()
219        .route("/v1/snapshot", get(snapshot))
220        .route("/v1/workers", get(list_workers))
221        .route("/v1/recurring", get(list_recurring))
222        .route("/metrics", get(metrics));
223    let body_limit = st.limits.max_http_body_bytes;
224    gated(api, &st, Role::Client)
225        .merge(gated(metrics, &st, Role::Metrics))
226        .merge(health_routes())
227        .fallback(|| async { ApiError(StatusCode::NOT_FOUND, "no such route".into()) })
228        .method_not_allowed_fallback(|| async {
229            ApiError(StatusCode::METHOD_NOT_ALLOWED, "method not allowed".into())
230        })
231        .layer(DefaultBodyLimit::max(body_limit))
232        .with_state(st)
233}
234
235/// Router for the optional unauthenticated metrics listener (`/metrics`, `/healthz`, `/readyz`).
236pub fn metrics_router(st: St) -> Router {
237    Router::new()
238        .route("/metrics", get(metrics))
239        .merge(health_routes())
240        .with_state(st)
241}
242
243fn health_routes() -> Router<St> {
244    Router::new()
245        .route("/healthz", get(|| async { "ok" }))
246        .route(
247            "/readyz",
248            get(|State(st): State<St>| async move {
249                if st.app().is_some() {
250                    (StatusCode::OK, "ready")
251                } else {
252                    (StatusCode::SERVICE_UNAVAILABLE, "standby")
253                }
254            }),
255        )
256}
257
258/// Serve `router` on `listener` (optionally over TLS) until `stop` is cancelled, then drain
259/// connections for up to `drain`.
260pub async fn serve(
261    listener: TcpListener,
262    router: Router,
263    tls: Option<tokio_rustls::TlsAcceptor>,
264    st: St,
265    stop: CancellationToken,
266    drain: Duration,
267) {
268    let mut builder = auto::Builder::new(TokioExecutor::new());
269    builder
270        .http1()
271        .timer(TokioTimer::new())
272        .header_read_timeout(Duration::from_secs(10));
273    builder
274        .http2()
275        .timer(TokioTimer::new())
276        .max_concurrent_streams(st.limits.max_concurrent_streams)
277        .keep_alive_interval(Some(st.limits.keepalive_interval()))
278        .keep_alive_timeout(st.limits.keepalive_timeout());
279    let graceful = hyper_util::server::graceful::GracefulShutdown::new();
280    let conns = Arc::new(tokio::sync::Semaphore::new(
281        st.limits.max_concurrent_requests.max(1),
282    ));
283    loop {
284        let (tcp, peer) = tokio::select! {
285            r = listener.accept() => match r {
286                Ok(c) => c,
287                Err(e) => {
288                    tracing::debug!(error = %e, "accept failed");
289                    tokio::time::sleep(Duration::from_millis(50)).await;
290                    continue;
291                }
292            },
293            _ = stop.cancelled() => break,
294        };
295        let Ok(permit) = conns.clone().try_acquire_owned() else {
296            tracing::warn!(%peer, "too many HTTP connections; refusing");
297            continue;
298        };
299        let _ = tcp.set_nodelay(true);
300        let tcp = crate::standby::CloseOnStandby::new(tcp, st.clone());
301        let (router, tls, st, builder) = (router.clone(), tls.clone(), st.clone(), builder.clone());
302        let watcher = graceful.watcher();
303        tokio::spawn(async move {
304            let _permit = permit;
305            match tls {
306                None => {
307                    let svc = service(router, ConnInfo::default());
308                    let conn = builder
309                        .serve_connection_with_upgrades(TokioIo::new(tcp), svc)
310                        .into_owned();
311                    let _ = watcher.watch(conn).await;
312                }
313                Some(acceptor) => {
314                    let stream =
315                        match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(tcp))
316                            .await
317                        {
318                            Ok(Ok(s)) => s,
319                            Ok(Err(e)) => {
320                                tracing::debug!(%peer, error = %e, "TLS handshake failed");
321                                return;
322                            }
323                            Err(_) => return,
324                        };
325                    let cert = stream
326                        .get_ref()
327                        .1
328                        .peer_certificates()
329                        .and_then(|c| c.first())
330                        .and_then(|c| st.auth.principal_for_cert(c.as_ref()));
331                    let svc = service(router, ConnInfo { cert });
332                    let conn = builder
333                        .serve_connection_with_upgrades(TokioIo::new(stream), svc)
334                        .into_owned();
335                    let _ = watcher.watch(conn).await;
336                }
337            }
338        });
339    }
340    drop(listener);
341    if tokio::time::timeout(drain, graceful.shutdown())
342        .await
343        .is_err()
344    {
345        tracing::debug!("HTTP connections did not drain in time");
346    }
347}
348
349fn service(
350    router: Router,
351    info: ConnInfo,
352) -> TowerToHyperService<
353    impl tower::Service<
354        hyper::Request<hyper::body::Incoming>,
355        Response = Response,
356        Error = Infallible,
357        Future = impl Send + 'static,
358    > + Clone
359    + Send
360    + 'static,
361> {
362    TowerToHyperService::new(router.map_request(
363        move |mut r: hyper::Request<hyper::body::Incoming>| {
364            r.extensions_mut().insert(info.clone());
365            r.map(axum::body::Body::new)
366        },
367    ))
368}
369
370// ---------------------------------------------------------------------------------------------
371// Request and response bodies.
372
373/// A task in JSON form.
374#[derive(Deserialize, Default)]
375#[serde(deny_unknown_fields)]
376pub struct TaskJson {
377    /// JSON payload (stored as `application/json`).
378    payload: Option<Value>,
379    /// Binary payload, base64-encoded.
380    payload_b64: Option<String>,
381    content_type: Option<String>,
382    kind: Option<String>,
383    pool: Option<String>,
384    priority: Option<String>,
385    key: Option<String>,
386    deadline_in_s: Option<f64>,
387    deadline_unix_ns: Option<i64>,
388    start_in_s: Option<f64>,
389    start_unix_ns: Option<i64>,
390    tolerance_s: Option<f64>,
391    on_miss: Option<String>,
392    duration_hint_s: Option<f64>,
393    timeout_s: Option<f64>,
394    retry: Option<RetryJson>,
395    /// Keys (strings) or ids (numbers, or `{"id": "..."}`) that must succeed first.
396    #[serde(default)]
397    after: Vec<Value>,
398    #[serde(default)]
399    deps: Vec<DepJson>,
400    #[serde(default)]
401    resources: HashMap<String, u32>,
402    #[serde(default)]
403    tags: HashMap<String, String>,
404    /// Worker labels the task requires.
405    #[serde(default)]
406    labels: HashMap<String, String>,
407    /// Tenant, for pools that share capacity fairly between tenants.
408    tenant: Option<String>,
409    /// Wait for the result (single submissions only).
410    #[serde(default)]
411    wait: bool,
412    /// Upper bound for `wait` (capped by `limits.max_wait_s`).
413    wait_timeout_s: Option<f64>,
414}
415
416#[derive(Deserialize)]
417#[serde(deny_unknown_fields)]
418struct RetryJson {
419    max_attempts: u32,
420    #[serde(default)]
421    initial_backoff_s: f64,
422    #[serde(default)]
423    multiplier: f64,
424    #[serde(default)]
425    max_backoff_s: f64,
426    #[serde(default = "yes")]
427    jitter: bool,
428}
429
430fn yes() -> bool {
431    true
432}
433
434#[derive(Deserialize)]
435#[serde(deny_unknown_fields)]
436struct DepJson {
437    /// Task id (string or number).
438    id: Option<Value>,
439    key: Option<String>,
440    #[serde(default)]
441    cond: Option<String>,
442}
443
444fn json_id(v: &Value) -> Result<u64, ApiError> {
445    match v {
446        Value::Number(n) => n.as_u64().ok_or_else(|| bad("invalid id")),
447        Value::String(s) => s.parse().map_err(|_| bad(format!("invalid id '{s}'"))),
448        _ => Err(bad("id must be a string or number")),
449    }
450}
451
452fn id_ref(id: u64) -> pb::TaskRef {
453    pb::TaskRef {
454        target: Some(pb::task_ref::Target::Id(id)),
455    }
456}
457
458fn key_ref(k: String) -> pb::TaskRef {
459    pb::TaskRef {
460        target: Some(pb::task_ref::Target::Key(k)),
461    }
462}
463
464impl TaskJson {
465    fn into_proto(self) -> Result<(pb::TaskSpec, bool, Option<f64>), ApiError> {
466        let (payload, default_ct) = match (self.payload, self.payload_b64) {
467            (Some(_), Some(_)) => return Err(bad("use either payload or payload_b64")),
468            (Some(v), None) => (
469                serde_json::to_vec(&v).map_err(|e| bad(e.to_string()))?,
470                "application/json",
471            ),
472            (None, Some(b)) => (
473                B64.decode(b)
474                    .map_err(|e| bad(format!("payload_b64: {e}")))?,
475                "application/octet-stream",
476            ),
477            (None, None) => (Vec::new(), ""),
478        };
479        let priority = match self.priority.as_deref() {
480            None | Some("normal") => pb::Priority::Normal,
481            Some("critical") => pb::Priority::Critical,
482            Some("high") => pb::Priority::High,
483            Some("low") => pb::Priority::Low,
484            Some(p) => {
485                return Err(bad(format!(
486                    "unknown priority '{p}' (critical, high, normal, low)"
487                )));
488            }
489        };
490        let on_miss = match self.on_miss.as_deref() {
491            None | Some("run_late") => pb::MissPolicy::RunLate,
492            Some("skip") => pb::MissPolicy::Skip,
493            Some("fail") => pb::MissPolicy::Fail,
494            Some(p) => return Err(bad(format!("unknown on_miss '{p}' (run_late, skip, fail)"))),
495        };
496        let mut deps = Vec::new();
497        for a in &self.after {
498            let target = match a {
499                Value::String(k) => key_ref(k.clone()),
500                Value::Number(_) => id_ref(json_id(a)?),
501                Value::Object(o) => match o.get("id") {
502                    Some(id) if o.len() == 1 => id_ref(json_id(id)?),
503                    _ => return Err(bad("`after` objects must be {\"id\": \"...\"}")),
504                },
505                _ => return Err(bad("`after` entries must be keys (strings) or ids")),
506            };
507            deps.push(pb::Dep {
508                target: Some(target),
509                cond: pb::EdgeCond::OnSuccess as i32,
510            });
511        }
512        for d in self.deps {
513            let target = match (d.id, d.key) {
514                (Some(id), None) => id_ref(json_id(&id)?),
515                (None, Some(k)) => key_ref(k),
516                _ => return Err(bad("dependency needs exactly one of id or key")),
517            };
518            let cond = match d.cond.as_deref() {
519                None | Some("on_success") => pb::EdgeCond::OnSuccess,
520                Some("on_complete") => pb::EdgeCond::OnComplete,
521                Some("on_failure") => pb::EdgeCond::OnFailure,
522                Some(c) => {
523                    return Err(bad(format!(
524                        "unknown cond '{c}' (on_success, on_complete, on_failure)"
525                    )));
526                }
527            };
528            deps.push(pb::Dep {
529                target: Some(target),
530                cond: cond as i32,
531            });
532        }
533        let spec = pb::TaskSpec {
534            payload,
535            content_type: self.content_type.unwrap_or_else(|| default_ct.into()),
536            kind: self.kind.unwrap_or_default(),
537            pool: self.pool.unwrap_or_default(),
538            priority: priority as i32,
539            key: self.key.unwrap_or_default(),
540            deadline_in_s: self.deadline_in_s,
541            deadline_unix_ns: self.deadline_unix_ns,
542            start_in_s: self.start_in_s,
543            start_unix_ns: self.start_unix_ns,
544            tolerance_s: self.tolerance_s,
545            on_miss: on_miss as i32,
546            duration_hint_s: self.duration_hint_s,
547            timeout_s: self.timeout_s,
548            retry: self.retry.map(|r| pb::RetryPolicy {
549                max_attempts: r.max_attempts,
550                initial_backoff_s: r.initial_backoff_s,
551                multiplier: r.multiplier,
552                max_backoff_s: r.max_backoff_s,
553                jitter: r.jitter,
554            }),
555            deps,
556            resources: self.resources,
557            tags: self.tags,
558            labels: self.labels,
559            tenant: self.tenant.unwrap_or_default(),
560        };
561        Ok((spec, self.wait, self.wait_timeout_s))
562    }
563}
564
565/// Lower-case name of a wire status.
566pub fn status_name(s: i32) -> &'static str {
567    match pb::TaskStatus::try_from(s).unwrap_or(pb::TaskStatus::Unspecified) {
568        pb::TaskStatus::Unspecified => "unknown",
569        pb::TaskStatus::Blocked => "blocked",
570        pb::TaskStatus::Scheduled => "scheduled",
571        pb::TaskStatus::Ready => "ready",
572        pb::TaskStatus::Running => "running",
573        pb::TaskStatus::Retrying => "retrying",
574        pb::TaskStatus::Succeeded => "succeeded",
575        pb::TaskStatus::Failed => "failed",
576        pb::TaskStatus::DeadLettered => "dead_lettered",
577        pb::TaskStatus::Cancelled => "cancelled",
578        pb::TaskStatus::Skipped => "skipped",
579        pb::TaskStatus::Missed => "missed",
580        pb::TaskStatus::Shed => "shed",
581    }
582}
583
584fn result_json(r: &pb::TaskResult) -> Value {
585    let mut v = json!({
586        "id": r.id.to_string(),
587        "status": status_name(r.status),
588        "output_available": r.output_available,
589    });
590    if !r.message.is_empty() {
591        v["message"] = r.message.clone().into();
592    }
593    if let Some(out) = &r.output {
594        v["output_b64"] = B64.encode(out).into();
595        if let Ok(j) = serde_json::from_slice::<Value>(out) {
596            v["output"] = j;
597        }
598    }
599    v
600}
601
602fn info_json(i: &pb::TaskInfo) -> Value {
603    let mut v = json!({
604        "id": i.id.to_string(),
605        "status": status_name(i.status),
606        "pool": i.pool,
607        "attempt": i.attempt,
608        "output_available": i.output_available,
609    });
610    if !i.key.is_empty() {
611        v["key"] = i.key.clone().into();
612    }
613    if !i.kind.is_empty() {
614        v["kind"] = i.kind.clone().into();
615    }
616    if i.max_attempts > 0 {
617        v["max_attempts"] = i.max_attempts.into();
618    }
619    for (name, t) in [
620        ("created_unix_ns", i.created_unix_ns),
621        ("started_unix_ns", i.started_unix_ns),
622        ("finished_unix_ns", i.finished_unix_ns),
623    ] {
624        if let Some(t) = t {
625            v[name] = t.to_string().into();
626        }
627    }
628    if !i.message.is_empty() {
629        v["message"] = i.message.clone().into();
630    }
631    if !i.tenant.is_empty() {
632        v["tenant"] = i.tenant.clone().into();
633    }
634    if let Some(p) = priority_name(i.priority) {
635        v["priority"] = p.into();
636    }
637    v
638}
639
640/// Lower-case name of a wire priority (`None` if unspecified).
641fn priority_name(p: i32) -> Option<&'static str> {
642    match pb::Priority::try_from(p).ok()? {
643        pb::Priority::Unspecified => None,
644        pb::Priority::Critical => Some("critical"),
645        pb::Priority::High => Some("high"),
646        pb::Priority::Normal => Some("normal"),
647        pb::Priority::Low => Some("low"),
648    }
649}
650
651fn overlap_name(o: i32) -> &'static str {
652    match pb::Overlap::try_from(o).unwrap_or(pb::Overlap::Unspecified) {
653        pb::Overlap::Unspecified | pb::Overlap::Skip => "skip",
654        pb::Overlap::Allow => "allow",
655        pb::Overlap::Queue => "queue",
656        pb::Overlap::QueueOnSuccess => "queue_on_success",
657    }
658}
659
660fn parse_id(s: &str) -> Result<TaskRef, ApiError> {
661    s.parse::<u64>()
662        .map(|id| TaskRef::Id(TaskId(id)))
663        .map_err(|_| {
664            bad(format!(
665                "invalid task id '{s}' (use /v1/tasks/by-key/{{key}} for keys)"
666            ))
667        })
668}
669
670fn key(app: &AppState, k: String) -> Result<TaskRef, ApiError> {
671    Ok(crate::convert::task_ref(Some(key_ref(k)), &app.limits)?)
672}
673
674// ---------------------------------------------------------------------------------------------
675// Handlers.
676
677async fn submit(
678    State(st): State<St>,
679    Extension(p): Extension<Principal>,
680    ApiJson(body): ApiJson<TaskJson>,
681) -> Result<Json<Value>, ApiError> {
682    let app = app(&st)?;
683    let (spec, wait, wait_timeout) = body.into_proto()?;
684    let wait = if wait {
685        Some(grpc::wait_bound(wait_timeout, &app.limits)?)
686    } else {
687        None
688    };
689    let r = crate::route::submit(app, &p, spec, wait, &st.stop).await?;
690    let mut v = json!({ "id": r.id.to_string(), "duplicate": r.duplicate });
691    if let Some(res) = &r.result {
692        v["result"] = result_json(res);
693    }
694    Ok(Json(v))
695}
696
697#[derive(Deserialize)]
698#[serde(deny_unknown_fields)]
699struct BatchJson {
700    tasks: Vec<TaskJson>,
701}
702
703async fn submit_batch(
704    State(st): State<St>,
705    Extension(p): Extension<Principal>,
706    ApiJson(body): ApiJson<BatchJson>,
707) -> Result<Json<Value>, ApiError> {
708    let app = app(&st)?;
709    let max = app.limits.max_tasks_per_batch;
710    if body.tasks.len() > max {
711        return Err(bad(format!("at most {max} tasks per batch")));
712    }
713    let specs = body
714        .tasks
715        .into_iter()
716        .enumerate()
717        .map(|(i, t)| {
718            let (spec, wait, _) = t.into_proto()?;
719            if wait {
720                return Err(bad(format!(
721                    "tasks[{i}]: `wait` is not supported in batches; wait on each id"
722                )));
723            }
724            Ok(spec)
725        })
726        .collect::<Result<Vec<_>, ApiError>>()?;
727    let r = crate::route::submit_batch(app, &p, specs).await?;
728    Ok(Json(json!({
729        "tasks": r.tasks.iter().map(|t| json!({"id": t.id.to_string(), "duplicate": t.duplicate})).collect::<Vec<_>>()
730    })))
731}
732
733async fn get_task(st: &Shared, p: &Principal, target: TaskRef) -> Result<Json<Value>, ApiError> {
734    let app = app(st)?;
735    let (info, missing) = crate::route::get(app, p, target).await?;
736    let mut v = info_json(&info);
737    note_missing(&mut v, &missing);
738    Ok(Json(v))
739}
740
741async fn get_by_id(
742    State(st): State<St>,
743    Extension(p): Extension<Principal>,
744    Path(id): Path<String>,
745) -> Result<Json<Value>, ApiError> {
746    get_task(&st, &p, parse_id(&id)?).await
747}
748
749async fn get_by_key(
750    State(st): State<St>,
751    Extension(p): Extension<Principal>,
752    Path(k): Path<String>,
753) -> Result<Json<Value>, ApiError> {
754    let target = key(app(&st)?, k)?;
755    get_task(&st, &p, target).await
756}
757
758async fn cancel_task(st: &Shared, p: &Principal, target: TaskRef) -> Result<Json<Value>, ApiError> {
759    let app = app(st)?;
760    Ok(Json(
761        json!({ "cancelled": crate::route::cancel(app, p, target).await? }),
762    ))
763}
764
765async fn cancel_by_id(
766    State(st): State<St>,
767    Extension(p): Extension<Principal>,
768    Path(id): Path<String>,
769) -> Result<Json<Value>, ApiError> {
770    cancel_task(&st, &p, parse_id(&id)?).await
771}
772
773async fn cancel_by_key(
774    State(st): State<St>,
775    Extension(p): Extension<Principal>,
776    Path(k): Path<String>,
777) -> Result<Json<Value>, ApiError> {
778    let target = key(app(&st)?, k)?;
779    cancel_task(&st, &p, target).await
780}
781
782#[derive(Deserialize)]
783#[serde(deny_unknown_fields)]
784struct WaitQuery {
785    timeout_s: Option<f64>,
786}
787
788async fn wait_task(
789    st: &Shared,
790    p: &Principal,
791    target: TaskRef,
792    q: WaitQuery,
793) -> Result<Json<Value>, ApiError> {
794    let app = app(st)?;
795    let timeout = grpc::wait_bound(q.timeout_s, &app.limits)?;
796    let (result, missing) = crate::route::wait(app, p, target, timeout, &st.stop).await?;
797    let mut v = result_json(&result);
798    note_missing(&mut v, &missing);
799    Ok(Json(v))
800}
801
802fn query(
803    q: Result<Query<WaitQuery>, axum::extract::rejection::QueryRejection>,
804) -> Result<WaitQuery, ApiError> {
805    q.map(|Query(q)| q).map_err(|e| bad(e.body_text()))
806}
807
808async fn result_by_id(
809    State(st): State<St>,
810    Extension(p): Extension<Principal>,
811    Path(id): Path<String>,
812    q: Result<Query<WaitQuery>, axum::extract::rejection::QueryRejection>,
813) -> Result<Json<Value>, ApiError> {
814    wait_task(&st, &p, parse_id(&id)?, query(q)?).await
815}
816
817async fn result_by_key(
818    State(st): State<St>,
819    Extension(p): Extension<Principal>,
820    Path(k): Path<String>,
821    q: Result<Query<WaitQuery>, axum::extract::rejection::QueryRejection>,
822) -> Result<Json<Value>, ApiError> {
823    let target = key(app(&st)?, k)?;
824    wait_task(&st, &p, target, query(q)?).await
825}
826
827#[derive(Deserialize)]
828#[serde(deny_unknown_fields)]
829struct ListQuery {
830    /// Comma-separated pool names.
831    pool: Option<String>,
832    /// Comma-separated status names.
833    status: Option<String>,
834    after: Option<String>,
835    limit: Option<u32>,
836}
837
838fn csv(s: Option<&str>) -> impl Iterator<Item = &str> {
839    s.unwrap_or_default()
840        .split(',')
841        .map(str::trim)
842        .filter(|p| !p.is_empty())
843}
844
845async fn list_tasks(
846    State(st): State<St>,
847    Extension(p): Extension<Principal>,
848    q: Result<Query<ListQuery>, axum::extract::rejection::QueryRejection>,
849) -> Result<Json<Value>, ApiError> {
850    let Query(q) = q.map_err(|e| bad(e.body_text()))?;
851    let mut statuses = Vec::new();
852    for name in csv(q.status.as_deref()) {
853        let s =
854            TaskStatus::from_name(name).ok_or_else(|| bad(format!("unknown status '{name}'")))?;
855        statuses.push(crate::convert::status(s) as i32);
856    }
857    let after = match q.after.as_deref() {
858        None | Some("") => 0,
859        Some(a) => a
860            .parse::<u64>()
861            .map_err(|_| bad(format!("invalid task id '{a}' in after")))?,
862    };
863    let req = pb::ListTasksRequest {
864        pools: csv(q.pool.as_deref()).map(str::to_string).collect(),
865        statuses,
866        after,
867        limit: q.limit.unwrap_or(0),
868    };
869    let r = crate::route::list_tasks(app(&st)?, &p, req).await?;
870    let mut v = json!({ "tasks": r.tasks.iter().map(info_json).collect::<Vec<_>>() });
871    note_missing(&mut v, &r.unavailable_shards);
872    if r.next_after > 0 {
873        v["next_after"] = r.next_after.to_string().into();
874    }
875    Ok(Json(v))
876}
877
878async fn list_workers(
879    State(st): State<St>,
880    Extension(p): Extension<Principal>,
881) -> Result<Json<Value>, ApiError> {
882    let r = crate::route::list_workers(app(&st)?, &p).await?;
883    let workers: Vec<Value> = r
884        .workers
885        .iter()
886        .map(|w| {
887            json!({
888                "id": w.id.to_string(), "name": w.name, "pool": w.pool, "labels": w.labels,
889                "slots": w.slots, "busy": w.busy,
890                "connected_unix_ns": w.connected_unix_ns.to_string(),
891            })
892        })
893        .collect();
894    let mut v = json!({ "workers": workers });
895    note_missing(&mut v, &r.unavailable_shards);
896    Ok(Json(v))
897}
898
899async fn list_recurring(
900    State(st): State<St>,
901    Extension(p): Extension<Principal>,
902) -> Result<Json<Value>, ApiError> {
903    let r = crate::route::list_recurring(app(&st)?, &p).await?;
904    let schedules: Vec<Value> = r
905        .schedules
906        .iter()
907        .map(|s| {
908            let mut v = json!({
909                "name": s.name, "cron": s.cron, "pool": s.pool, "kind": s.kind,
910                "overlap": overlap_name(s.overlap),
911            });
912            if !s.tz.is_empty() {
913                v["tz"] = s.tz.clone().into();
914            }
915            if let Some(t) = s.next_unix_ns {
916                v["next_unix_ns"] = t.to_string().into();
917            }
918            if let Some(id) = s.last_task_id {
919                v["last_task_id"] = id.to_string().into();
920            }
921            v
922        })
923        .collect();
924    let mut v = json!({ "schedules": schedules });
925    note_missing(&mut v, &r.unavailable_shards);
926    Ok(Json(v))
927}
928
929/// Name the shards a combined answer is missing, if any.
930fn note_missing(v: &mut Value, shards: &[String]) {
931    if !shards.is_empty() {
932        v["unavailable_shards"] = shards.into();
933    }
934}
935
936async fn snapshot(
937    State(st): State<St>,
938    Extension(p): Extension<Principal>,
939) -> Result<Json<Value>, ApiError> {
940    let s = crate::route::snapshot(app(&st)?, &p).await?;
941    let pools: Vec<Value> = s
942        .pools
943        .iter()
944        .map(|p| {
945            json!({
946                "name": p.name, "capacity": p.capacity, "busy": p.busy, "held": p.held,
947                "desired": p.desired, "pending": p.pending_by_class.iter().sum::<u64>(),
948                "pending_by_class": p.pending_by_class, "reservations": p.reservations,
949                "dispatched": p.dispatched, "succeeded": p.succeeded, "failed": p.failed,
950                "priority_adherence": p.priority_adherence, "fixed_start_hit_rate": p.fixed_start_hit_rate,
951                "utilization": p.utilization, "schedule_efficiency": p.schedule_efficiency,
952                "wait_p50_s": p.wait_p50_s, "wait_p90_s": p.wait_p90_s, "wait_p99_s": p.wait_p99_s,
953                "cost": p.cost, "workers_connected": p.workers_connected,
954                "groups": p.groups.iter().map(|g| json!({
955                    "labels": g.labels, "capacity": g.capacity, "busy": g.busy,
956                })).collect::<Vec<_>>(),
957            })
958        })
959        .collect();
960    let t = s.totals.unwrap_or_default();
961    Ok(Json(json!({
962        "pools": pools, "live_tasks": s.live_tasks, "running": s.running, "pending": s.pending,
963        "workers_connected": s.workers_connected, "unavailable_shards": s.unavailable_shards,
964        "totals": {
965            "submitted": t.submitted, "dispatched": t.dispatched, "succeeded": t.succeeded,
966            "failed": t.failed, "dead_lettered": t.dead_lettered, "cancelled": t.cancelled,
967            "skipped": t.skipped, "missed": t.missed, "shed": t.shed, "retries": t.retries,
968            "timeouts": t.timeouts, "lease_expiries": t.lease_expiries,
969            "stale_completions": t.stale_completions,
970        },
971    })))
972}
973
974async fn metrics(State(st): State<St>) -> Result<Response, ApiError> {
975    let s = grpc::snapshot(app(&st)?).await?;
976    Ok((
977        [(header::CONTENT_TYPE, crate::metrics::CONTENT_TYPE)],
978        crate::metrics::render(&s),
979    )
980        .into_response())
981}