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") && req.timezone.parse::<chrono_tz::Tz>().is_err() {
86        return Err(AppError::Internal(anyhow::anyhow!(
87            "invalid timezone: {}",
88            req.timezone
89        )));
90    }
91
92    let schedule = WorkflowSchedule {
93        name: req.name.clone(),
94        namespace: req.namespace.clone(),
95        workflow_type: req.workflow_type,
96        cron_expr: req.cron_expr,
97        timezone: req.timezone,
98        input: req.input.map(|v| v.to_string()),
99        task_queue: req.task_queue,
100        overlap_policy: req.overlap_policy,
101        paused: false,
102        last_run_at: None,
103        next_run_at: None,
104        last_workflow_id: None,
105        created_at: now,
106    };
107
108    state.create_schedule(&schedule).await?;
109
110    Ok((
111        axum::http::StatusCode::CREATED,
112        Json(serde_json::to_value(schedule)?),
113    ))
114}
115
116#[derive(Deserialize)]
117pub struct NsQuery {
118    #[serde(default = "default_namespace")]
119    namespace: String,
120}
121
122#[utoipa::path(
123    get, path = "/api/v1/engine/workflow/schedules",
124    tag = "schedules",
125    params(("namespace" = Option<String>, Query, description = "Namespace (default: main)")),
126    responses((status = 200, description = "List of schedules", body = Vec<WorkflowSchedule>)),
127)]
128pub async fn list_schedules<S: WorkflowStore>(
129    State(state): State<Arc<WorkflowCtx<S>>>,
130    Query(q): Query<NsQuery>,
131) -> Result<Json<Vec<serde_json::Value>>, AppError> {
132    let schedules = state.list_schedules(&q.namespace).await?;
133    let json: Vec<serde_json::Value> = schedules
134        .into_iter()
135        .map(|s| serde_json::to_value(s).unwrap_or_default())
136        .collect();
137    Ok(Json(json))
138}
139
140#[utoipa::path(
141    get, path = "/api/v1/engine/workflow/schedules/{name}",
142    tag = "schedules",
143    params(("name" = String, Path, description = "Schedule name")),
144    responses(
145        (status = 200, description = "Schedule details", body = WorkflowSchedule),
146        (status = 404, description = "Schedule not found"),
147    ),
148)]
149pub async fn get_schedule<S: WorkflowStore>(
150    State(state): State<Arc<WorkflowCtx<S>>>,
151    Path(name): Path<String>,
152    Query(q): Query<NsQuery>,
153) -> Result<Json<serde_json::Value>, AppError> {
154    let schedule = state
155        .get_schedule(&q.namespace, &name)
156        .await?
157        .ok_or(AppError::NotFound(format!("schedule {name}")))?;
158
159    Ok(Json(serde_json::to_value(schedule)?))
160}
161
162#[utoipa::path(
163    delete, path = "/api/v1/engine/workflow/schedules/{name}",
164    tag = "schedules",
165    params(("name" = String, Path, description = "Schedule name")),
166    responses(
167        (status = 200, description = "Schedule deleted"),
168        (status = 404, description = "Schedule not found"),
169    ),
170)]
171pub async fn delete_schedule<S: WorkflowStore>(
172    State(state): State<Arc<WorkflowCtx<S>>>,
173    Path(name): Path<String>,
174    Query(q): Query<NsQuery>,
175) -> Result<axum::http::StatusCode, AppError> {
176    let deleted = state.delete_schedule(&q.namespace, &name).await?;
177    if deleted {
178        Ok(axum::http::StatusCode::OK)
179    } else {
180        Err(AppError::NotFound(format!("schedule {name}")))
181    }
182}
183
184#[derive(Deserialize, ToSchema)]
185pub struct PatchScheduleRequest {
186    /// New cron expression (leave null to keep the existing one).
187    pub cron_expr: Option<String>,
188    /// New IANA timezone (e.g. "Europe/Berlin"; leave null to keep).
189    pub timezone: Option<String>,
190    /// New JSON input passed to each workflow run. Send `null` literally
191    /// to preserve; use `{}` to pass an empty object.
192    pub input: Option<serde_json::Value>,
193    /// New task queue for created workflows.
194    pub task_queue: Option<String>,
195    /// New overlap policy (skip, queue, cancel_old, allow_all).
196    pub overlap_policy: Option<String>,
197}
198
199#[utoipa::path(
200    patch, path = "/api/v1/engine/workflow/schedules/{name}",
201    tag = "schedules",
202    params(
203        ("name" = String, Path, description = "Schedule name"),
204        ("namespace" = Option<String>, Query, description = "Namespace (default: main)"),
205    ),
206    request_body = PatchScheduleRequest,
207    responses(
208        (status = 200, description = "Schedule updated", body = WorkflowSchedule),
209        (status = 404, description = "Schedule not found"),
210    ),
211)]
212pub async fn patch_schedule<S: WorkflowStore>(
213    State(state): State<Arc<WorkflowCtx<S>>>,
214    Path(name): Path<String>,
215    Query(q): Query<NsQuery>,
216    Json(req): Json<PatchScheduleRequest>,
217) -> Result<Json<serde_json::Value>, AppError> {
218    // Validate timezone before committing the write — same as create.
219    if let Some(ref tz) = req.timezone
220        && !tz.eq_ignore_ascii_case("UTC")
221        && tz.parse::<chrono_tz::Tz>().is_err()
222    {
223        return Err(AppError::Internal(anyhow::anyhow!(
224            "invalid timezone: {tz}"
225        )));
226    }
227
228    let patch = SchedulePatch {
229        cron_expr: req.cron_expr,
230        timezone: req.timezone,
231        input: req.input,
232        task_queue: req.task_queue,
233        overlap_policy: req.overlap_policy,
234    };
235
236    let updated = state
237        .update_schedule(&q.namespace, &name, &patch)
238        .await?
239        .ok_or_else(|| AppError::NotFound(format!("schedule {name}")))?;
240
241    Ok(Json(serde_json::to_value(updated)?))
242}
243
244#[utoipa::path(
245    post, path = "/api/v1/engine/workflow/schedules/{name}/pause",
246    tag = "schedules",
247    params(
248        ("name" = String, Path, description = "Schedule name"),
249        ("namespace" = Option<String>, Query, description = "Namespace (default: main)"),
250    ),
251    responses(
252        (status = 200, description = "Schedule paused", body = WorkflowSchedule),
253        (status = 404, description = "Schedule not found"),
254    ),
255)]
256pub async fn pause_schedule<S: WorkflowStore>(
257    State(state): State<Arc<WorkflowCtx<S>>>,
258    Path(name): Path<String>,
259    Query(q): Query<NsQuery>,
260) -> Result<Json<serde_json::Value>, AppError> {
261    let updated = state
262        .set_schedule_paused(&q.namespace, &name, true)
263        .await?
264        .ok_or_else(|| AppError::NotFound(format!("schedule {name}")))?;
265    Ok(Json(serde_json::to_value(updated)?))
266}
267
268#[utoipa::path(
269    post, path = "/api/v1/engine/workflow/schedules/{name}/resume",
270    tag = "schedules",
271    params(
272        ("name" = String, Path, description = "Schedule name"),
273        ("namespace" = Option<String>, Query, description = "Namespace (default: main)"),
274    ),
275    responses(
276        (status = 200, description = "Schedule resumed", body = WorkflowSchedule),
277        (status = 404, description = "Schedule not found"),
278    ),
279)]
280pub async fn resume_schedule<S: WorkflowStore>(
281    State(state): State<Arc<WorkflowCtx<S>>>,
282    Path(name): Path<String>,
283    Query(q): Query<NsQuery>,
284) -> Result<Json<serde_json::Value>, AppError> {
285    let updated = state
286        .set_schedule_paused(&q.namespace, &name, false)
287        .await?
288        .ok_or_else(|| AppError::NotFound(format!("schedule {name}")))?;
289    Ok(Json(serde_json::to_value(updated)?))
290}
291
292fn timestamp_now() -> f64 {
293    std::time::SystemTime::now()
294        .duration_since(std::time::UNIX_EPOCH)
295        .unwrap()
296        .as_secs_f64()
297}