Skip to main content

assay_workflow/api/
schedules.rs

1use std::sync::Arc;
2
3use axum::extract::{Path, Query, State};
4use axum::routing::{get, post};
5use axum::{Json, Router};
6use serde::Deserialize;
7use utoipa::ToSchema;
8
9use crate::api::workflows::AppError;
10use crate::ctx::WorkflowCtx;
11use crate::store::WorkflowStore;
12use crate::types::{SchedulePatch, WorkflowSchedule};
13
14pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<WorkflowCtx<S>>> {
15    Router::new()
16        .route("/schedules", post(create_schedule).get(list_schedules))
17        .route(
18            "/schedules/{name}",
19            get(get_schedule)
20                .patch(patch_schedule)
21                .delete(delete_schedule),
22        )
23        .route("/schedules/{name}/pause", post(pause_schedule))
24        .route("/schedules/{name}/resume", post(resume_schedule))
25}
26
27#[derive(Deserialize, ToSchema)]
28pub struct CreateScheduleRequest {
29    /// Unique schedule name
30    pub name: String,
31    /// Namespace (default: "main")
32    #[serde(default = "default_namespace")]
33    pub namespace: String,
34    /// Workflow type to start on each trigger
35    pub workflow_type: String,
36    /// Cron expression (e.g. "0 * * * *" for hourly)
37    pub cron_expr: String,
38    /// IANA time-zone name used to interpret `cron_expr`
39    /// (e.g. "Europe/Berlin", "America/New_York"). Default: "UTC".
40    #[serde(default = "default_timezone")]
41    pub timezone: String,
42    /// Optional JSON input passed to each workflow run
43    pub input: Option<serde_json::Value>,
44    /// Task queue for created workflows (default: "main")
45    #[serde(default = "default_queue")]
46    pub task_queue: String,
47    /// Overlap policy: skip, queue, cancel_old, allow_all (default: "skip")
48    #[serde(default = "default_overlap")]
49    pub overlap_policy: String,
50}
51
52fn default_queue() -> String {
53    "main".to_string()
54}
55
56fn default_namespace() -> String {
57    "main".to_string()
58}
59
60fn default_overlap() -> String {
61    "skip".to_string()
62}
63
64fn default_timezone() -> String {
65    "UTC".to_string()
66}
67
68#[utoipa::path(
69    post, path = "/api/v1/engine/workflow/schedules",
70    tag = "schedules",
71    request_body = CreateScheduleRequest,
72    responses(
73        (status = 201, description = "Schedule created", body = WorkflowSchedule),
74        (status = 500, description = "Internal error"),
75    ),
76)]
77pub async fn create_schedule<S: WorkflowStore>(
78    State(state): State<Arc<WorkflowCtx<S>>>,
79    Json(req): Json<CreateScheduleRequest>,
80) -> Result<(axum::http::StatusCode, Json<serde_json::Value>), AppError> {
81    let now = timestamp_now();
82
83    // Validate the timezone early so a bad value produces a clean 400
84    // instead of a mysterious silent no-op from the scheduler later.
85    if !req.timezone.eq_ignore_ascii_case("UTC")
86        && req.timezone.parse::<chrono_tz::Tz>().is_err()
87    {
88        return Err(AppError::Internal(anyhow::anyhow!(
89            "invalid timezone: {}",
90            req.timezone
91        )));
92    }
93
94    let schedule = WorkflowSchedule {
95        name: req.name.clone(),
96        namespace: req.namespace.clone(),
97        workflow_type: req.workflow_type,
98        cron_expr: req.cron_expr,
99        timezone: req.timezone,
100        input: req.input.map(|v| v.to_string()),
101        task_queue: req.task_queue,
102        overlap_policy: req.overlap_policy,
103        paused: false,
104        last_run_at: None,
105        next_run_at: None,
106        last_workflow_id: None,
107        created_at: now,
108    };
109
110    state.create_schedule(&schedule).await?;
111
112    Ok((
113        axum::http::StatusCode::CREATED,
114        Json(serde_json::to_value(schedule)?),
115    ))
116}
117
118#[derive(Deserialize)]
119pub struct NsQuery {
120    #[serde(default = "default_namespace")]
121    namespace: String,
122}
123
124#[utoipa::path(
125    get, path = "/api/v1/engine/workflow/schedules",
126    tag = "schedules",
127    params(("namespace" = Option<String>, Query, description = "Namespace (default: main)")),
128    responses((status = 200, description = "List of schedules", body = Vec<WorkflowSchedule>)),
129)]
130pub async fn list_schedules<S: WorkflowStore>(
131    State(state): State<Arc<WorkflowCtx<S>>>,
132    Query(q): Query<NsQuery>,
133) -> Result<Json<Vec<serde_json::Value>>, AppError> {
134    let schedules = state.list_schedules(&q.namespace).await?;
135    let json: Vec<serde_json::Value> = schedules
136        .into_iter()
137        .map(|s| serde_json::to_value(s).unwrap_or_default())
138        .collect();
139    Ok(Json(json))
140}
141
142#[utoipa::path(
143    get, path = "/api/v1/engine/workflow/schedules/{name}",
144    tag = "schedules",
145    params(("name" = String, Path, description = "Schedule name")),
146    responses(
147        (status = 200, description = "Schedule details", body = WorkflowSchedule),
148        (status = 404, description = "Schedule not found"),
149    ),
150)]
151pub async fn get_schedule<S: WorkflowStore>(
152    State(state): State<Arc<WorkflowCtx<S>>>,
153    Path(name): Path<String>,
154    Query(q): Query<NsQuery>,
155) -> Result<Json<serde_json::Value>, AppError> {
156    let schedule = state
157        .get_schedule(&q.namespace, &name)
158        .await?
159        .ok_or(AppError::NotFound(format!("schedule {name}")))?;
160
161    Ok(Json(serde_json::to_value(schedule)?))
162}
163
164#[utoipa::path(
165    delete, path = "/api/v1/engine/workflow/schedules/{name}",
166    tag = "schedules",
167    params(("name" = String, Path, description = "Schedule name")),
168    responses(
169        (status = 200, description = "Schedule deleted"),
170        (status = 404, description = "Schedule not found"),
171    ),
172)]
173pub async fn delete_schedule<S: WorkflowStore>(
174    State(state): State<Arc<WorkflowCtx<S>>>,
175    Path(name): Path<String>,
176    Query(q): Query<NsQuery>,
177) -> Result<axum::http::StatusCode, AppError> {
178    let deleted = state.delete_schedule(&q.namespace, &name).await?;
179    if deleted {
180        Ok(axum::http::StatusCode::OK)
181    } else {
182        Err(AppError::NotFound(format!("schedule {name}")))
183    }
184}
185
186#[derive(Deserialize, ToSchema)]
187pub struct PatchScheduleRequest {
188    /// New cron expression (leave null to keep the existing one).
189    pub cron_expr: Option<String>,
190    /// New IANA timezone (e.g. "Europe/Berlin"; leave null to keep).
191    pub timezone: Option<String>,
192    /// New JSON input passed to each workflow run. Send `null` literally
193    /// to preserve; use `{}` to pass an empty object.
194    pub input: Option<serde_json::Value>,
195    /// New task queue for created workflows.
196    pub task_queue: Option<String>,
197    /// New overlap policy (skip, queue, cancel_old, allow_all).
198    pub overlap_policy: Option<String>,
199}
200
201#[utoipa::path(
202    patch, path = "/api/v1/engine/workflow/schedules/{name}",
203    tag = "schedules",
204    params(
205        ("name" = String, Path, description = "Schedule name"),
206        ("namespace" = Option<String>, Query, description = "Namespace (default: main)"),
207    ),
208    request_body = PatchScheduleRequest,
209    responses(
210        (status = 200, description = "Schedule updated", body = WorkflowSchedule),
211        (status = 404, description = "Schedule not found"),
212    ),
213)]
214pub async fn patch_schedule<S: WorkflowStore>(
215    State(state): State<Arc<WorkflowCtx<S>>>,
216    Path(name): Path<String>,
217    Query(q): Query<NsQuery>,
218    Json(req): Json<PatchScheduleRequest>,
219) -> Result<Json<serde_json::Value>, AppError> {
220    // Validate timezone before committing the write — same as create.
221    if let Some(ref tz) = req.timezone
222        && !tz.eq_ignore_ascii_case("UTC")
223        && tz.parse::<chrono_tz::Tz>().is_err()
224    {
225        return Err(AppError::Internal(anyhow::anyhow!(
226            "invalid timezone: {tz}"
227        )));
228    }
229
230    let patch = SchedulePatch {
231        cron_expr: req.cron_expr,
232        timezone: req.timezone,
233        input: req.input,
234        task_queue: req.task_queue,
235        overlap_policy: req.overlap_policy,
236    };
237
238    let updated = state
239        .update_schedule(&q.namespace, &name, &patch)
240        .await?
241        .ok_or_else(|| AppError::NotFound(format!("schedule {name}")))?;
242
243    Ok(Json(serde_json::to_value(updated)?))
244}
245
246#[utoipa::path(
247    post, path = "/api/v1/engine/workflow/schedules/{name}/pause",
248    tag = "schedules",
249    params(
250        ("name" = String, Path, description = "Schedule name"),
251        ("namespace" = Option<String>, Query, description = "Namespace (default: main)"),
252    ),
253    responses(
254        (status = 200, description = "Schedule paused", body = WorkflowSchedule),
255        (status = 404, description = "Schedule not found"),
256    ),
257)]
258pub async fn pause_schedule<S: WorkflowStore>(
259    State(state): State<Arc<WorkflowCtx<S>>>,
260    Path(name): Path<String>,
261    Query(q): Query<NsQuery>,
262) -> Result<Json<serde_json::Value>, AppError> {
263    let updated = state
264        .set_schedule_paused(&q.namespace, &name, true)
265        .await?
266        .ok_or_else(|| AppError::NotFound(format!("schedule {name}")))?;
267    Ok(Json(serde_json::to_value(updated)?))
268}
269
270#[utoipa::path(
271    post, path = "/api/v1/engine/workflow/schedules/{name}/resume",
272    tag = "schedules",
273    params(
274        ("name" = String, Path, description = "Schedule name"),
275        ("namespace" = Option<String>, Query, description = "Namespace (default: main)"),
276    ),
277    responses(
278        (status = 200, description = "Schedule resumed", body = WorkflowSchedule),
279        (status = 404, description = "Schedule not found"),
280    ),
281)]
282pub async fn resume_schedule<S: WorkflowStore>(
283    State(state): State<Arc<WorkflowCtx<S>>>,
284    Path(name): Path<String>,
285    Query(q): Query<NsQuery>,
286) -> Result<Json<serde_json::Value>, AppError> {
287    let updated = state
288        .set_schedule_paused(&q.namespace, &name, false)
289        .await?
290        .ok_or_else(|| AppError::NotFound(format!("schedule {name}")))?;
291    Ok(Json(serde_json::to_value(updated)?))
292}
293
294fn timestamp_now() -> f64 {
295    std::time::SystemTime::now()
296        .duration_since(std::time::UNIX_EPOCH)
297        .unwrap()
298        .as_secs_f64()
299}