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 pub name: String,
31 #[serde(default = "default_namespace")]
33 pub namespace: String,
34 pub workflow_type: String,
36 pub cron_expr: String,
38 #[serde(default = "default_timezone")]
41 pub timezone: String,
42 pub input: Option<serde_json::Value>,
44 #[serde(default = "default_queue")]
46 pub task_queue: String,
47 #[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 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 pub cron_expr: Option<String>,
188 pub timezone: Option<String>,
190 pub input: Option<serde_json::Value>,
193 pub task_queue: Option<String>,
195 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 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}