1use axum::extract::{Query, State};
4use axum::response::IntoResponse;
5use ironflow_auth::extractor::Authenticated;
6use serde::{Deserialize, Serialize};
7
8use crate::error::ApiError;
9use crate::response::ok;
10use crate::state::AppState;
11
12#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams, utoipa::ToSchema))]
14#[derive(Debug, Deserialize)]
15pub struct ListWorkflowsQuery {
16 pub name: Option<String>,
18 pub category: Option<String>,
24}
25
26#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[derive(Debug, Serialize, Deserialize)]
29pub struct WorkflowSummary {
30 pub name: String,
32 pub category: Option<String>,
34}
35
36pub const UNCATEGORIZED_FILTER: &str = "__uncategorized__";
39
40#[cfg_attr(
49 feature = "openapi",
50 utoipa::path(
51 get,
52 path = "/api/v1/workflows",
53 tags = ["workflows"],
54 params(ListWorkflowsQuery),
55 responses(
56 (status = 200, description = "List of workflow summaries", body = [WorkflowSummary]),
57 (status = 401, description = "Unauthorized")
58 ),
59 security(("Bearer" = []))
60 )
61)]
62pub async fn list_workflows(
63 _auth: Authenticated,
64 State(state): State<AppState>,
65 Query(params): Query<ListWorkflowsQuery>,
66) -> Result<impl IntoResponse, ApiError> {
67 let mut summaries: Vec<WorkflowSummary> = state
68 .engine
69 .handler_names()
70 .into_iter()
71 .map(|name| {
72 let category = state
73 .engine
74 .handler_info(name)
75 .and_then(|info| info.category);
76 WorkflowSummary {
77 name: name.to_string(),
78 category,
79 }
80 })
81 .collect();
82
83 if let Some(ref filter) = params.name {
84 let lower = filter.to_lowercase();
85 summaries.retain(|s| s.name.to_lowercase().contains(&lower));
86 }
87
88 if let Some(ref cat_filter) = params.category {
89 if cat_filter == UNCATEGORIZED_FILTER {
90 summaries.retain(|s| s.category.is_none());
91 } else {
92 let needle = cat_filter.to_lowercase();
93 summaries.retain(|s| {
94 s.category
95 .as_deref()
96 .is_some_and(|c| c.to_lowercase().contains(&needle))
97 });
98 }
99 }
100
101 summaries.sort_by(|a, b| {
102 a.category
103 .as_deref()
104 .unwrap_or("")
105 .cmp(b.category.as_deref().unwrap_or(""))
106 .then_with(|| a.name.cmp(&b.name))
107 });
108
109 Ok(ok(summaries))
110}
111
112#[cfg(test)]
113mod tests {
114 use axum::Router;
115 use axum::body::Body;
116 use axum::http::{Request, StatusCode};
117 use axum::routing::get;
118 use http_body_util::BodyExt;
119 use ironflow_auth::jwt::AccessToken;
120 use ironflow_core::providers::claude::ClaudeCodeProvider;
121 use ironflow_engine::context::WorkflowContext;
122 use ironflow_engine::engine::Engine;
123 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
124 use ironflow_engine::notify::Event;
125 use ironflow_store::memory::InMemoryStore;
126 use serde_json::{Value as JsonValue, from_slice, from_value};
127 use std::sync::Arc;
128 use tokio::sync::broadcast;
129 use tower::ServiceExt;
130 use uuid::Uuid;
131
132 use super::*;
133
134 fn make_auth_header(state: &AppState) -> String {
135 let user_id = Uuid::now_v7();
136 let token = AccessToken::for_user(user_id, "testuser", false, &state.jwt_config).unwrap();
137 format!("Bearer {}", token.0)
138 }
139
140 struct TestWorkflow;
141
142 impl WorkflowHandler for TestWorkflow {
143 fn name(&self) -> &str {
144 "test-workflow"
145 }
146
147 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
148 Box::pin(async move { Ok(()) })
149 }
150 }
151
152 struct AnotherWorkflow;
153
154 impl WorkflowHandler for AnotherWorkflow {
155 fn name(&self) -> &str {
156 "another-workflow"
157 }
158
159 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
160 Box::pin(async move { Ok(()) })
161 }
162 }
163
164 struct EtlNightlyWorkflow;
165
166 impl WorkflowHandler for EtlNightlyWorkflow {
167 fn name(&self) -> &str {
168 "etl-nightly"
169 }
170 fn category(&self) -> Option<&str> {
171 Some("data/etl")
172 }
173 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
174 Box::pin(async move { Ok(()) })
175 }
176 }
177
178 struct ReportsDailyWorkflow;
179
180 impl WorkflowHandler for ReportsDailyWorkflow {
181 fn name(&self) -> &str {
182 "reports-daily"
183 }
184 fn category(&self) -> Option<&str> {
185 Some("data/reports")
186 }
187 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
188 Box::pin(async move { Ok(()) })
189 }
190 }
191
192 fn base_state(engine: Engine) -> AppState {
193 let store = Arc::new(InMemoryStore::new());
194 Arc::new(InMemoryStore::new());
195 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
196 secret: "test-secret".to_string(),
197 access_token_ttl_secs: 900,
198 refresh_token_ttl_secs: 604800,
199 cookie_domain: None,
200 cookie_secure: false,
201 });
202 let (event_sender, _) = broadcast::channel::<Event>(1);
203 AppState::new(
204 store,
205 Arc::new(engine),
206 jwt_config,
207 "test-worker-token".to_string(),
208 event_sender,
209 )
210 }
211
212 fn test_state() -> AppState {
213 let store = Arc::new(InMemoryStore::new());
214 let provider = Arc::new(ClaudeCodeProvider::new());
215 let mut engine = Engine::new(store.clone(), provider);
216 engine.register(TestWorkflow).unwrap();
217 engine.register(AnotherWorkflow).unwrap();
218 base_state(engine)
219 }
220
221 fn test_state_with_categories() -> AppState {
222 let store = Arc::new(InMemoryStore::new());
223 let provider = Arc::new(ClaudeCodeProvider::new());
224 let mut engine = Engine::new(store.clone(), provider);
225 engine.register(TestWorkflow).unwrap();
226 engine.register(EtlNightlyWorkflow).unwrap();
227 engine.register(ReportsDailyWorkflow).unwrap();
228 base_state(engine)
229 }
230
231 async fn run_request(state: AppState, uri: &str) -> (StatusCode, Vec<WorkflowSummary>) {
232 let auth_header = make_auth_header(&state);
233 let app = Router::new()
234 .route("/", get(list_workflows))
235 .with_state(state);
236
237 let req = Request::builder()
238 .uri(uri)
239 .header("authorization", auth_header)
240 .body(Body::empty())
241 .unwrap();
242
243 let resp = app.oneshot(req).await.unwrap();
244 let status = resp.status();
245 let body = resp.into_body().collect().await.unwrap().to_bytes();
246 let json_val: JsonValue = from_slice(&body).unwrap();
247 let summaries: Vec<WorkflowSummary> = from_value(json_val["data"].clone()).unwrap();
248 (status, summaries)
249 }
250
251 #[tokio::test]
252 async fn list_workflows_empty() {
253 let store = Arc::new(InMemoryStore::new());
254 let provider = Arc::new(ClaudeCodeProvider::new());
255 let engine = Engine::new(store.clone(), provider);
256 let state = base_state(engine);
257
258 let (status, summaries) = run_request(state, "/").await;
259 assert_eq!(status, StatusCode::OK);
260 assert!(summaries.is_empty());
261 }
262
263 #[tokio::test]
264 async fn list_workflows_multiple_returns_summaries() {
265 let state = test_state();
266 let (status, summaries) = run_request(state, "/").await;
267 assert_eq!(status, StatusCode::OK);
268 assert_eq!(summaries.len(), 2);
269 assert!(summaries.iter().any(|s| s.name == "test-workflow"));
270 assert!(summaries.iter().any(|s| s.name == "another-workflow"));
271 assert!(summaries.iter().all(|s| s.category.is_none()));
272 }
273
274 #[tokio::test]
275 async fn list_workflows_filtered_by_name() {
276 let state = test_state();
277 let (_, summaries) = run_request(state, "/?name=test").await;
278 assert_eq!(summaries.len(), 1);
279 assert_eq!(summaries[0].name, "test-workflow");
280 }
281
282 #[tokio::test]
283 async fn list_workflows_filter_name_case_insensitive() {
284 let state = test_state();
285 let (_, summaries) = run_request(state, "/?name=TEST").await;
286 assert_eq!(summaries.len(), 1);
287 assert_eq!(summaries[0].name, "test-workflow");
288 }
289
290 #[tokio::test]
291 async fn list_workflows_filter_no_match() {
292 let state = test_state();
293 let (_, summaries) = run_request(state, "/?name=nonexistent").await;
294 assert!(summaries.is_empty());
295 }
296
297 #[tokio::test]
298 async fn list_workflows_returns_category_when_present() {
299 let state = test_state_with_categories();
300 let (_, summaries) = run_request(state, "/").await;
301 let etl = summaries.iter().find(|s| s.name == "etl-nightly").unwrap();
302 assert_eq!(etl.category.as_deref(), Some("data/etl"));
303 let test = summaries
304 .iter()
305 .find(|s| s.name == "test-workflow")
306 .unwrap();
307 assert!(test.category.is_none());
308 }
309
310 #[tokio::test]
311 async fn list_workflows_filter_by_category_partial() {
312 let state = test_state_with_categories();
313 let (_, summaries) = run_request(state, "/?category=data").await;
314 assert_eq!(summaries.len(), 2);
315 assert!(
316 summaries
317 .iter()
318 .all(|s| { s.category.as_deref().is_some_and(|c| c.contains("data")) })
319 );
320 }
321
322 #[tokio::test]
323 async fn list_workflows_filter_by_category_nested_substring() {
324 let state = test_state_with_categories();
325 let (_, summaries) = run_request(state, "/?category=etl").await;
326 assert_eq!(summaries.len(), 1);
327 assert_eq!(summaries[0].name, "etl-nightly");
328 }
329
330 #[tokio::test]
331 async fn list_workflows_filter_by_category_case_insensitive() {
332 let state = test_state_with_categories();
333 let (_, summaries) = run_request(state, "/?category=DATA").await;
334 assert_eq!(summaries.len(), 2);
335 let (_, summaries) = run_request(test_state_with_categories(), "/?category=Etl").await;
336 assert_eq!(summaries.len(), 1);
337 assert_eq!(summaries[0].name, "etl-nightly");
338 }
339
340 #[tokio::test]
341 async fn list_workflows_filter_by_category_no_match_excludes_uncategorized() {
342 let state = test_state_with_categories();
343 let (_, summaries) = run_request(state, "/?category=nonexistent").await;
344 assert!(summaries.is_empty());
345 }
346
347 #[tokio::test]
348 async fn list_workflows_filter_uncategorized_sentinel() {
349 let state = test_state_with_categories();
350 let (_, summaries) = run_request(state, "/?category=__uncategorized__").await;
351 assert_eq!(summaries.len(), 1);
352 assert_eq!(summaries[0].name, "test-workflow");
353 }
354}