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