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