chronon_axum/lib.rs
1//! Axum HTTP API for Chronon (`/api/chronon/*`).
2//!
3//! Mounts job, run, and script routes on a host Axum server. Handlers delegate to
4//! [`chronon_runtime::CoordinatorService`] and read script metadata from
5//! [`chronon_executor::ScriptRegistry`].
6//!
7//! # Features
8//!
9//! - **AdminAuth** — host-supplied [`AdminAuth`] via [`ChrononStateBuilder`]; lab helpers
10//! [`StaticTokenAdminAuth`] / [`AllowAllAdminAuth`]. Production identity belongs to Higgs.
11//! - **`CHRONON_REQUIRE_ADMIN_AUTH`** — when set, [`ChrononStateBuilder::build`] and
12//! [`RequireAdmin`] fail closed without a verifier.
13//! - **External actor policy** — HTTP upsert rejects System-shaped `actor_json`
14//! ([`RejectExternalSystemActor`](chronon_core::RejectExternalSystemActor)).
15//! - **Error hygiene** — HTTP envelopes sanitize/redact credentials in error strings.
16//!
17//! # Security
18//!
19//! Wrap-before-public-bind: nest under [`API_PREFIX`], install [`AdminAuth`] (or host middleware),
20//! and set `CHRONON_REQUIRE_ADMIN_AUTH=1` before exposing the API. See repository `SECURITY.md`
21//! and the `axum_auth_wrap` example.
22//!
23//! # Routes
24//!
25//! - `GET/POST /jobs/*` — list, upsert (by `job_name`), pause, resume, run now
26//! - `GET /runs/*` — list (limit capped at 1000) and fetch runs
27//! - `GET /scripts` — list registered scripts
28//! - `GET /jobs/{id}/revisions` — revision metadata with actor/params redacted
29//!
30//! All responses use the [`ApiResponse`] envelope (`success`, `data`, `error`).
31//! [`UpsertJobRequest::script_name`] must exist in the registry or upsert returns 400.
32//! Concurrency, timeout, and retry knobs are clamped to production ceilings.
33//!
34//! # Remote HTTP clients
35//!
36//! Mount this router on an embedded or coordinator–worker host **behind host auth**, then point
37//! [`chronon_runtime::RemoteCoordinatorClient`] at `{base_url}` (paths under
38//! [`API_PREFIX`]). See the `chronon` crate [Remote HTTP client](https://docs.rs/uf-chronon/latest/chronon/index.html#remote-http-client) section.
39//!
40//! # Examples
41//!
42//! Completed setup with [`RequireAdmin`] / [`StaticTokenAdminAuth`]:
43//!
44//! ```
45//! use std::sync::Arc;
46//! use axum::extract::FromRef;
47//! use axum::Router;
48//! use chronon_axum::{
49//! chronon_router, ChrononState, StaticTokenAdminAuth, API_PREFIX,
50//! };
51//! use chronon_backend_mem::InMemorySchedulerStore;
52//! use chronon_core::{Result as ChrononResult, ScriptContext};
53//! use chronon_executor::{ScriptDescriptor, ScriptRegistry};
54//! use chronon_runtime::CoordinatorService;
55//!
56//! fn noop(
57//! _ctx: Box<dyn ScriptContext>,
58//! _params: serde_json::Value,
59//! ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ChrononResult<()>> + Send>> {
60//! Box::pin(async { Ok(()) })
61//! }
62//!
63//! #[derive(Clone)]
64//! struct AppState {
65//! chronon: ChrononState,
66//! }
67//!
68//! impl FromRef<AppState> for ChrononState {
69//! fn from_ref(state: &AppState) -> Self {
70//! state.chronon.clone()
71//! }
72//! }
73//!
74//! # fn mount() -> chronon_core::Result<Router<AppState>> {
75//! let store = Arc::new(InMemorySchedulerStore::new());
76//! let coordinator = Arc::new(CoordinatorService::new(store));
77//! let registry = Arc::new({
78//! let mut r = ScriptRegistry::new();
79//! r.register(&ScriptDescriptor::new("demo", noop));
80//! r
81//! });
82//! let chronon = ChrononState::builder(coordinator, registry)
83//! .admin_auth(Arc::new(StaticTokenAdminAuth::new("lab-token")))
84//! .require_admin_auth(true)
85//! .build()?;
86//! Ok(Router::new()
87//! .nest(API_PREFIX, chronon_router::<AppState>())
88//! .with_state(AppState { chronon }))
89//! # }
90//! ```
91//!
92//! Runnable:
93//! `cargo run -p uf-chronon --example axum_host --features mem,axum`,
94//! `axum_auth_wrap`, and
95//! `remote_http_client` (client against a nested router).
96
97mod auth;
98mod dto;
99mod handlers;
100mod handlers_common;
101mod state;
102
103use axum::{
104 extract::FromRef,
105 routing::{get, post},
106 Router,
107};
108
109pub use auth::{
110 require_admin_auth_from_env, AdminAuth, AdminAuthError, AllowAllAdminAuth, RequireAdmin,
111 StaticTokenAdminAuth, REQUIRE_ADMIN_AUTH_ENV,
112};
113pub use dto::{
114 JobActionRequest, JobResponse, ListJobsQuery, ListRunsQuery, RunResponse, ScheduleKindDto,
115 ScriptResponse, UpsertJobRequest,
116};
117pub use handlers_common::ApiResponse;
118pub use state::{ChrononState, ChrononStateBuilder, HttpUpsertActorProvider};
119
120/// API mount prefix for host routers (e.g. `nest(API_PREFIX, chronon_router())`).
121pub const API_PREFIX: &str = "/api/chronon";
122
123/// Create the Chronon API router with job, run, and script routes.
124///
125/// Host state `S` must implement [`FromRef<S>`] for [`ChrononState`]. Nest under
126/// [`API_PREFIX`] (`/api/chronon`) so [`chronon_runtime::RemoteCoordinatorClient`] paths match.
127///
128/// Handlers extract [`RequireAdmin`]. Install [`AdminAuth`] on [`ChrononState`] (or set
129/// `CHRONON_REQUIRE_ADMIN_AUTH=1` and fail closed) before public bind. See `SECURITY.md`.
130///
131/// See the crate-level example (RequireAdmin + StaticTokenAdminAuth).
132pub fn chronon_router<S>() -> Router<S>
133where
134 S: Clone + Send + Sync + 'static,
135 ChrononState: FromRef<S>,
136{
137 Router::new()
138 .route("/jobs", get(handlers::list_jobs))
139 .route("/jobs/upsert", post(handlers::upsert_job))
140 .route("/jobs/pause", post(handlers::pause_job))
141 .route("/jobs/resume", post(handlers::resume_job))
142 .route("/jobs/run_now", post(handlers::run_now))
143 .route("/jobs/{id}", get(handlers::get_job))
144 .route("/jobs/{id}/revisions", get(handlers::get_job_revisions))
145 .route("/runs", get(handlers::list_runs))
146 .route("/runs/{id}", get(handlers::get_run))
147 .route("/scripts", get(handlers::list_scripts))
148}
149
150#[cfg(test)]
151mod tests {
152 #![allow(clippy::unwrap_used, clippy::expect_used)]
153
154 use std::sync::Arc;
155
156 use chronon_backend_mem::InMemorySchedulerStore;
157 use chronon_core::{Result, ScriptContext};
158 use chronon_executor::{ScriptDescriptor, ScriptRegistry};
159 use chronon_runtime::CoordinatorService;
160
161 use crate::ChrononState;
162
163 fn noop(
164 _ctx: Box<dyn ScriptContext>,
165 _params: serde_json::Value,
166 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send>> {
167 Box::pin(async { Ok(()) })
168 }
169
170 fn parts() -> (Arc<CoordinatorService>, Arc<ScriptRegistry>) {
171 let store = Arc::new(InMemorySchedulerStore::new());
172 let coordinator = Arc::new(CoordinatorService::new(store));
173 let registry = Arc::new({
174 let mut r = ScriptRegistry::new();
175 r.register(&ScriptDescriptor::new("demo", noop));
176 r
177 });
178 (coordinator, registry)
179 }
180
181 #[test]
182 fn builder_requires_auth_when_flagged() {
183 let (coordinator, registry) = parts();
184 let result = ChrononState::builder(coordinator, registry)
185 .require_admin_auth(true)
186 .build();
187 let Err(err) = result else {
188 panic!("must fail without AdminAuth");
189 };
190 assert!(err.to_string().contains("AdminAuth"));
191 }
192}