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")
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 pub cron_expr: Option<String>,
190 pub timezone: Option<String>,
192 pub input: Option<serde_json::Value>,
195 pub task_queue: Option<String>,
197 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 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}