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 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 pub cron_expr: Option<String>,
196 pub timezone: Option<String>,
198 pub input: Option<serde_json::Value>,
201 pub task_queue: Option<String>,
203 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 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}