1use std::collections::HashMap;
4
5use axum::extract::{Query, State};
6use axum::response::IntoResponse;
7use chrono::{DateTime, Utc};
8use ironflow_auth::extractor::Authenticated;
9use serde::{Deserialize, Serialize};
10
11use crate::error::ApiError;
12use crate::response::ok;
13use crate::state::AppState;
14
15#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams, utoipa::ToSchema))]
17#[derive(Debug, Deserialize)]
18pub struct ListWorkflowsQuery {
19 pub name: Option<String>,
21 pub category: Option<String>,
27}
28
29#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31#[derive(Debug, Serialize, Deserialize)]
32pub struct WorkflowSummary {
33 pub name: String,
35 pub category: Option<String>,
37 pub version: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub schedule: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub paused_at: Option<DateTime<Utc>>,
46}
47
48pub const UNCATEGORIZED_FILTER: &str = "__uncategorized__";
51
52#[cfg_attr(
61 feature = "openapi",
62 utoipa::path(
63 get,
64 path = "/api/v1/workflows",
65 tags = ["workflows"],
66 params(ListWorkflowsQuery),
67 responses(
68 (status = 200, description = "List of workflow summaries", body = [WorkflowSummary]),
69 (status = 401, description = "Unauthorized")
70 ),
71 security(("Bearer" = []))
72 )
73)]
74pub async fn list_workflows(
75 _auth: Authenticated,
76 State(state): State<AppState>,
77 Query(params): Query<ListWorkflowsQuery>,
78) -> Result<impl IntoResponse, ApiError> {
79 let paused_at: HashMap<String, DateTime<Utc>> = state
80 .store
81 .list_workflow_pauses()
82 .await?
83 .into_iter()
84 .map(|pause| (pause.workflow_name, pause.paused_at))
85 .collect();
86
87 let mut summaries: Vec<WorkflowSummary> = state
88 .engine
89 .handler_names()
90 .into_iter()
91 .map(|name| {
92 let info = state.engine.handler_info(name);
93 let category = info.as_ref().and_then(|i| i.category.clone());
94 let version = info.as_ref().and_then(|i| i.version.clone());
95 let schedule = info.and_then(|i| i.schedule.map(|s| s.as_str().to_string()));
96 WorkflowSummary {
97 name: name.to_string(),
98 category,
99 version,
100 schedule,
101 paused_at: paused_at.get(name).copied(),
102 }
103 })
104 .collect();
105
106 if let Some(ref filter) = params.name {
107 let lower = filter.to_lowercase();
108 summaries.retain(|s| s.name.to_lowercase().contains(&lower));
109 }
110
111 if let Some(ref cat_filter) = params.category {
112 if cat_filter == UNCATEGORIZED_FILTER {
113 summaries.retain(|s| s.category.is_none());
114 } else {
115 let needle = cat_filter.to_lowercase();
116 summaries.retain(|s| {
117 s.category
118 .as_deref()
119 .is_some_and(|c| c.to_lowercase().contains(&needle))
120 });
121 }
122 }
123
124 summaries.sort_by(|a, b| {
125 a.category
126 .as_deref()
127 .unwrap_or("")
128 .cmp(b.category.as_deref().unwrap_or(""))
129 .then_with(|| a.name.cmp(&b.name))
130 });
131
132 Ok(ok(summaries))
133}
134
135#[cfg(test)]
136mod tests {
137 use axum::Router;
138 use axum::body::Body;
139 use axum::http::{Request, StatusCode};
140 use axum::routing::get;
141 use http_body_util::BodyExt;
142 use ironflow_core::providers::claude::ClaudeCodeProvider;
143 use ironflow_engine::context::WorkflowContext;
144 use ironflow_engine::engine::Engine;
145 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
146 use ironflow_engine::notify::Event;
147 use ironflow_store::memory::InMemoryStore;
148 use serde_json::{Value as JsonValue, from_slice, from_value};
149 use std::sync::Arc;
150 use tokio::sync::broadcast;
151 use tower::ServiceExt;
152
153 use super::*;
154 use crate::routes::test_helpers::create_user_auth_header;
155
156 struct TestWorkflow;
157
158 impl WorkflowHandler for TestWorkflow {
159 fn name(&self) -> &str {
160 "test-workflow"
161 }
162
163 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
164 Box::pin(async move { Ok(()) })
165 }
166 }
167
168 struct AnotherWorkflow;
169
170 impl WorkflowHandler for AnotherWorkflow {
171 fn name(&self) -> &str {
172 "another-workflow"
173 }
174
175 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
176 Box::pin(async move { Ok(()) })
177 }
178 }
179
180 struct EtlNightlyWorkflow;
181
182 impl WorkflowHandler for EtlNightlyWorkflow {
183 fn name(&self) -> &str {
184 "etl-nightly"
185 }
186 fn category(&self) -> Option<&str> {
187 Some("data/etl")
188 }
189 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
190 Box::pin(async move { Ok(()) })
191 }
192 }
193
194 struct ReportsDailyWorkflow;
195
196 impl WorkflowHandler for ReportsDailyWorkflow {
197 fn name(&self) -> &str {
198 "reports-daily"
199 }
200 fn category(&self) -> Option<&str> {
201 Some("data/reports")
202 }
203 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
204 Box::pin(async move { Ok(()) })
205 }
206 }
207
208 fn base_state(engine: Engine) -> AppState {
209 let store = Arc::new(InMemoryStore::new());
210 Arc::new(InMemoryStore::new());
211 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
212 secret: "test-secret".to_string(),
213 access_token_ttl_secs: 900,
214 refresh_token_ttl_secs: 604800,
215 cookie_domain: None,
216 cookie_secure: false,
217 });
218 let (event_sender, _) = broadcast::channel::<Event>(1);
219 AppState::new(
220 store,
221 Arc::new(engine),
222 jwt_config,
223 "test-worker-token".to_string(),
224 event_sender,
225 )
226 }
227
228 fn test_state() -> AppState {
229 let store = Arc::new(InMemoryStore::new());
230 let provider = Arc::new(ClaudeCodeProvider::new());
231 let mut engine = Engine::new(store.clone(), provider);
232 engine.register(TestWorkflow).unwrap();
233 engine.register(AnotherWorkflow).unwrap();
234 base_state(engine)
235 }
236
237 fn test_state_with_categories() -> AppState {
238 let store = Arc::new(InMemoryStore::new());
239 let provider = Arc::new(ClaudeCodeProvider::new());
240 let mut engine = Engine::new(store.clone(), provider);
241 engine.register(TestWorkflow).unwrap();
242 engine.register(EtlNightlyWorkflow).unwrap();
243 engine.register(ReportsDailyWorkflow).unwrap();
244 base_state(engine)
245 }
246
247 async fn run_request(state: AppState, uri: &str) -> (StatusCode, Vec<WorkflowSummary>) {
248 let auth_header = create_user_auth_header(&state, "testuser", false).await;
249 let app = Router::new()
250 .route("/", get(list_workflows))
251 .with_state(state);
252
253 let req = Request::builder()
254 .uri(uri)
255 .header("authorization", auth_header)
256 .body(Body::empty())
257 .unwrap();
258
259 let resp = app.oneshot(req).await.unwrap();
260 let status = resp.status();
261 let body = resp.into_body().collect().await.unwrap().to_bytes();
262 let json_val: JsonValue = from_slice(&body).unwrap();
263 let summaries: Vec<WorkflowSummary> = from_value(json_val["data"].clone()).unwrap();
264 (status, summaries)
265 }
266
267 #[tokio::test]
268 async fn list_workflows_empty() {
269 let store = Arc::new(InMemoryStore::new());
270 let provider = Arc::new(ClaudeCodeProvider::new());
271 let engine = Engine::new(store.clone(), provider);
272 let state = base_state(engine);
273
274 let (status, summaries) = run_request(state, "/").await;
275 assert_eq!(status, StatusCode::OK);
276 assert!(summaries.is_empty());
277 }
278
279 #[tokio::test]
280 async fn list_workflows_multiple_returns_summaries() {
281 let state = test_state();
282 let (status, summaries) = run_request(state, "/").await;
283 assert_eq!(status, StatusCode::OK);
284 assert_eq!(summaries.len(), 2);
285 assert!(summaries.iter().any(|s| s.name == "test-workflow"));
286 assert!(summaries.iter().any(|s| s.name == "another-workflow"));
287 assert!(summaries.iter().all(|s| s.category.is_none()));
288 }
289
290 #[tokio::test]
291 async fn list_workflows_filtered_by_name() {
292 let state = test_state();
293 let (_, summaries) = run_request(state, "/?name=test").await;
294 assert_eq!(summaries.len(), 1);
295 assert_eq!(summaries[0].name, "test-workflow");
296 }
297
298 #[tokio::test]
299 async fn list_workflows_filter_name_case_insensitive() {
300 let state = test_state();
301 let (_, summaries) = run_request(state, "/?name=TEST").await;
302 assert_eq!(summaries.len(), 1);
303 assert_eq!(summaries[0].name, "test-workflow");
304 }
305
306 #[tokio::test]
307 async fn list_workflows_filter_no_match() {
308 let state = test_state();
309 let (_, summaries) = run_request(state, "/?name=nonexistent").await;
310 assert!(summaries.is_empty());
311 }
312
313 #[tokio::test]
314 async fn list_workflows_returns_category_when_present() {
315 let state = test_state_with_categories();
316 let (_, summaries) = run_request(state, "/").await;
317 let etl = summaries.iter().find(|s| s.name == "etl-nightly").unwrap();
318 assert_eq!(etl.category.as_deref(), Some("data/etl"));
319 let test = summaries
320 .iter()
321 .find(|s| s.name == "test-workflow")
322 .unwrap();
323 assert!(test.category.is_none());
324 }
325
326 #[tokio::test]
327 async fn list_workflows_filter_by_category_partial() {
328 let state = test_state_with_categories();
329 let (_, summaries) = run_request(state, "/?category=data").await;
330 assert_eq!(summaries.len(), 2);
331 assert!(
332 summaries
333 .iter()
334 .all(|s| { s.category.as_deref().is_some_and(|c| c.contains("data")) })
335 );
336 }
337
338 #[tokio::test]
339 async fn list_workflows_filter_by_category_nested_substring() {
340 let state = test_state_with_categories();
341 let (_, summaries) = run_request(state, "/?category=etl").await;
342 assert_eq!(summaries.len(), 1);
343 assert_eq!(summaries[0].name, "etl-nightly");
344 }
345
346 #[tokio::test]
347 async fn list_workflows_filter_by_category_case_insensitive() {
348 let state = test_state_with_categories();
349 let (_, summaries) = run_request(state, "/?category=DATA").await;
350 assert_eq!(summaries.len(), 2);
351 let (_, summaries) = run_request(test_state_with_categories(), "/?category=Etl").await;
352 assert_eq!(summaries.len(), 1);
353 assert_eq!(summaries[0].name, "etl-nightly");
354 }
355
356 #[tokio::test]
357 async fn list_workflows_filter_by_category_no_match_excludes_uncategorized() {
358 let state = test_state_with_categories();
359 let (_, summaries) = run_request(state, "/?category=nonexistent").await;
360 assert!(summaries.is_empty());
361 }
362
363 #[tokio::test]
364 async fn list_workflows_filter_uncategorized_sentinel() {
365 let state = test_state_with_categories();
366 let (_, summaries) = run_request(state, "/?category=__uncategorized__").await;
367 assert_eq!(summaries.len(), 1);
368 assert_eq!(summaries[0].name, "test-workflow");
369 }
370
371 struct ScheduledWorkflow {
372 schedule: ironflow_engine::prelude::CronSchedule,
373 }
374 impl ScheduledWorkflow {
375 fn new() -> Self {
376 Self {
377 schedule: ironflow_engine::prelude::CronSchedule::new("0 30 9 * * MON-FRI")
378 .unwrap(),
379 }
380 }
381 }
382
383 impl WorkflowHandler for ScheduledWorkflow {
384 fn name(&self) -> &str {
385 "scheduled-workflow"
386 }
387 fn schedule(&self) -> Option<&ironflow_engine::prelude::CronSchedule> {
388 Some(&self.schedule)
389 }
390 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
391 Box::pin(async move { Ok(()) })
392 }
393 }
394
395 #[tokio::test]
396 async fn list_workflows_returns_schedule_when_present() {
397 let store = Arc::new(InMemoryStore::new());
398 let provider = Arc::new(ClaudeCodeProvider::new());
399 let mut engine = Engine::new(store.clone(), provider);
400 engine.register(TestWorkflow).unwrap();
401 engine.register(ScheduledWorkflow::new()).unwrap();
402 let state = base_state(engine);
403
404 let (_, summaries) = run_request(state, "/").await;
405 let scheduled = summaries
406 .iter()
407 .find(|s| s.name == "scheduled-workflow")
408 .unwrap();
409 assert_eq!(scheduled.schedule.as_deref(), Some("0 30 9 * * MON-FRI"));
410
411 let unscheduled = summaries
412 .iter()
413 .find(|s| s.name == "test-workflow")
414 .unwrap();
415 assert!(unscheduled.schedule.is_none());
416 }
417
418 #[tokio::test]
419 async fn list_workflows_returns_paused_at_for_paused_workflows() {
420 let state = test_state();
421 let pause = state
422 .store
423 .pause_workflow("test-workflow", None)
424 .await
425 .unwrap();
426
427 let (_, summaries) = run_request(state, "/").await;
428 let paused = summaries
429 .iter()
430 .find(|s| s.name == "test-workflow")
431 .unwrap();
432 assert_eq!(paused.paused_at, Some(pause.paused_at));
433 let active = summaries
434 .iter()
435 .find(|s| s.name == "another-workflow")
436 .unwrap();
437 assert!(active.paused_at.is_none());
438 }
439}