1use std::sync::Arc;
2
3use axum::extract::{Path, Query, State};
4use axum::routing::{get, post};
5use axum::{Json, Router};
6use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
8
9use crate::ctx::WorkflowCtx;
10use crate::store::WorkflowStore;
11use crate::types::WorkflowStatus;
12
13pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<WorkflowCtx<S>>> {
14 Router::new()
15 .route("/workflows", post(start_workflow).get(list_workflows))
16 .route("/workflows/{id}", get(describe_workflow))
17 .route("/workflows/{id}/events", get(get_events))
18 .route("/workflows/{id}/signal/{name}", post(send_signal))
19 .route("/workflows/{id}/cancel", post(cancel_workflow))
20 .route("/workflows/{id}/terminate", post(terminate_workflow))
21 .route("/workflows/{id}/children", get(list_children))
22 .route("/workflows/{id}/continue-as-new", post(continue_as_new))
23 .route("/workflows/{id}/state", get(get_workflow_state))
24 .route("/workflows/{id}/state/{name}", get(get_workflow_state_by_name))
25}
26
27#[derive(Deserialize, ToSchema)]
28pub struct StartWorkflowRequest {
29 pub namespace: Option<String>,
31 pub workflow_type: String,
33 pub workflow_id: String,
35 pub input: Option<serde_json::Value>,
37 #[serde(default = "default_queue")]
39 pub task_queue: String,
40 pub search_attributes: Option<serde_json::Value>,
44}
45
46fn default_queue() -> String {
47 "main".to_string()
48}
49
50#[derive(Serialize, ToSchema)]
51pub struct WorkflowResponse {
52 pub workflow_id: String,
53 pub run_id: String,
54 pub status: String,
55}
56
57#[utoipa::path(
58 post, path = "/api/v1/workflows",
59 tag = "workflows",
60 request_body = StartWorkflowRequest,
61 responses(
62 (status = 201, description = "Workflow started", body = WorkflowResponse),
63 (status = 500, description = "Internal error"),
64 ),
65)]
66pub async fn start_workflow<S: WorkflowStore>(
67 State(state): State<Arc<WorkflowCtx<S>>>,
68 Json(req): Json<StartWorkflowRequest>,
69) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
70 let input = req.input.map(|v| v.to_string());
71 let namespace = req.namespace.as_deref().unwrap_or("main");
72 let search_attributes = req.search_attributes.map(|v| v.to_string());
73 let wf = state
74 .start_workflow(
75 namespace,
76 &req.workflow_type,
77 &req.workflow_id,
78 input.as_deref(),
79 &req.task_queue,
80 search_attributes.as_deref(),
81 )
82 .await?;
83
84 Ok((
85 axum::http::StatusCode::CREATED,
86 Json(WorkflowResponse {
87 workflow_id: wf.id,
88 run_id: wf.run_id,
89 status: wf.status,
90 }),
91 ))
92}
93
94#[derive(Deserialize)]
95pub struct ListQuery {
96 #[serde(default = "default_namespace")]
97 pub namespace: String,
98 pub status: Option<String>,
99 #[serde(rename = "type")]
100 pub workflow_type: Option<String>,
101 pub search_attrs: Option<String>,
105 #[serde(default = "default_limit")]
106 pub limit: i64,
107 #[serde(default)]
108 pub offset: i64,
109}
110
111fn default_namespace() -> String {
112 "main".to_string()
113}
114
115fn default_limit() -> i64 {
116 50
117}
118
119#[utoipa::path(
120 get, path = "/api/v1/workflows",
121 tag = "workflows",
122 params(
123 ("status" = Option<String>, Query, description = "Filter by status"),
124 ("type" = Option<String>, Query, description = "Filter by workflow type"),
125 ("limit" = Option<i64>, Query, description = "Max results (default 50)"),
126 ("offset" = Option<i64>, Query, description = "Pagination offset"),
127 ),
128 responses(
129 (status = 200, description = "List of workflows", body = Vec<WorkflowRecord>),
130 ),
131)]
132pub async fn list_workflows<S: WorkflowStore>(
133 State(state): State<Arc<WorkflowCtx<S>>>,
134 Query(q): Query<ListQuery>,
135) -> Result<Json<Vec<serde_json::Value>>, AppError> {
136 let status = q
137 .status
138 .as_deref()
139 .and_then(|s| s.parse::<WorkflowStatus>().ok());
140
141 let workflows = state
142 .list_workflows(
143 &q.namespace,
144 status,
145 q.workflow_type.as_deref(),
146 q.search_attrs.as_deref(),
147 q.limit,
148 q.offset,
149 )
150 .await?;
151
152 let json: Vec<serde_json::Value> = workflows
153 .into_iter()
154 .map(|w| serde_json::to_value(w).unwrap_or_default())
155 .collect();
156
157 Ok(Json(json))
158}
159
160#[utoipa::path(
161 get, path = "/api/v1/workflows/{id}",
162 tag = "workflows",
163 params(("id" = String, Path, description = "Workflow ID")),
164 responses(
165 (status = 200, description = "Workflow details", body = WorkflowRecord),
166 (status = 404, description = "Workflow not found"),
167 ),
168)]
169pub async fn describe_workflow<S: WorkflowStore>(
170 State(state): State<Arc<WorkflowCtx<S>>>,
171 Path(id): Path<String>,
172) -> Result<Json<serde_json::Value>, AppError> {
173 let wf = state
174 .get_workflow(&id)
175 .await?
176 .ok_or(AppError::NotFound(format!("workflow {id}")))?;
177
178 Ok(Json(serde_json::to_value(wf)?))
179}
180
181#[utoipa::path(
182 get, path = "/api/v1/workflows/{id}/events",
183 tag = "workflows",
184 params(("id" = String, Path, description = "Workflow ID")),
185 responses(
186 (status = 200, description = "Event history", body = Vec<WorkflowEvent>),
187 ),
188)]
189pub async fn get_events<S: WorkflowStore>(
190 State(state): State<Arc<WorkflowCtx<S>>>,
191 Path(id): Path<String>,
192) -> Result<Json<Vec<serde_json::Value>>, AppError> {
193 let events = state.get_events(&id).await?;
194 let json: Vec<serde_json::Value> = events
195 .into_iter()
196 .map(|e| serde_json::to_value(e).unwrap_or_default())
197 .collect();
198 Ok(Json(json))
199}
200
201#[derive(Deserialize, ToSchema)]
202pub struct SignalBody {
203 pub payload: Option<serde_json::Value>,
204}
205
206#[utoipa::path(
207 post, path = "/api/v1/workflows/{id}/signal/{name}",
208 tag = "workflows",
209 params(
210 ("id" = String, Path, description = "Workflow ID"),
211 ("name" = String, Path, description = "Signal name"),
212 ),
213 responses(
214 (status = 200, description = "Signal sent"),
215 ),
216)]
217pub async fn send_signal<S: WorkflowStore>(
218 State(state): State<Arc<WorkflowCtx<S>>>,
219 Path((id, name)): Path<(String, String)>,
220 Json(body): Json<Option<SignalBody>>,
221) -> Result<axum::http::StatusCode, AppError> {
222 let payload = body.and_then(|b| b.payload).map(|v| v.to_string());
223 state
224 .send_signal(&id, &name, payload.as_deref())
225 .await?;
226 Ok(axum::http::StatusCode::OK)
227}
228
229#[derive(Deserialize, ToSchema, Default)]
230pub struct CancelBody {
231 pub reason: Option<String>,
235}
236
237#[utoipa::path(
238 post, path = "/api/v1/workflows/{id}/cancel",
239 tag = "workflows",
240 params(("id" = String, Path, description = "Workflow ID")),
241 request_body = CancelBody,
242 responses(
243 (status = 200, description = "Workflow cancelled"),
244 (status = 404, description = "Workflow not found or already terminal"),
245 ),
246)]
247pub async fn cancel_workflow<S: WorkflowStore>(
248 State(state): State<Arc<WorkflowCtx<S>>>,
249 Path(id): Path<String>,
250 body: Option<Json<CancelBody>>,
251) -> Result<axum::http::StatusCode, AppError> {
252 let reason = body.and_then(|Json(b)| b.reason);
255 let cancelled = state.cancel_workflow(&id, reason.as_deref()).await?;
256 if cancelled {
257 Ok(axum::http::StatusCode::OK)
258 } else {
259 Err(AppError::NotFound(format!(
260 "workflow {id} not found or already terminal"
261 )))
262 }
263}
264
265#[derive(Deserialize, ToSchema)]
266pub struct TerminateBody {
267 pub reason: Option<String>,
268}
269
270#[utoipa::path(
271 post, path = "/api/v1/workflows/{id}/terminate",
272 tag = "workflows",
273 params(("id" = String, Path, description = "Workflow ID")),
274 responses(
275 (status = 200, description = "Workflow terminated"),
276 (status = 404, description = "Workflow not found or already terminal"),
277 ),
278)]
279pub async fn terminate_workflow<S: WorkflowStore>(
280 State(state): State<Arc<WorkflowCtx<S>>>,
281 Path(id): Path<String>,
282 Json(body): Json<Option<TerminateBody>>,
283) -> Result<axum::http::StatusCode, AppError> {
284 let reason = body.and_then(|b| b.reason);
285 let terminated = state
286 .terminate_workflow(&id, reason.as_deref())
287 .await?;
288 if terminated {
289 Ok(axum::http::StatusCode::OK)
290 } else {
291 Err(AppError::NotFound(format!(
292 "workflow {id} not found or already terminal"
293 )))
294 }
295}
296
297#[utoipa::path(
298 get, path = "/api/v1/workflows/{id}/children",
299 tag = "workflows",
300 params(("id" = String, Path, description = "Parent workflow ID")),
301 responses(
302 (status = 200, description = "Child workflows", body = Vec<WorkflowRecord>),
303 ),
304)]
305pub async fn list_children<S: WorkflowStore>(
306 State(state): State<Arc<WorkflowCtx<S>>>,
307 Path(id): Path<String>,
308) -> Result<Json<Vec<serde_json::Value>>, AppError> {
309 let children = state.list_child_workflows(&id).await?;
310 let json: Vec<serde_json::Value> = children
311 .into_iter()
312 .map(|w| serde_json::to_value(w).unwrap_or_default())
313 .collect();
314 Ok(Json(json))
315}
316
317#[derive(Deserialize, ToSchema)]
318pub struct ContinueAsNewBody {
319 pub input: Option<serde_json::Value>,
321 pub workflow_id: Option<String>,
326}
327
328#[utoipa::path(
329 post, path = "/api/v1/workflows/{id}/continue-as-new",
330 tag = "workflows",
331 params(("id" = String, Path, description = "Workflow ID to continue")),
332 request_body = ContinueAsNewBody,
333 responses(
334 (status = 201, description = "New workflow run started", body = WorkflowResponse),
335 ),
336)]
337pub async fn continue_as_new<S: WorkflowStore>(
338 State(state): State<Arc<WorkflowCtx<S>>>,
339 Path(id): Path<String>,
340 Json(body): Json<ContinueAsNewBody>,
341) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
342 let input = body.input.map(|v| v.to_string());
343 let new_id = body
344 .workflow_id
345 .as_deref()
346 .filter(|s| !s.trim().is_empty());
347 let wf = state
348 .continue_as_new(&id, input.as_deref(), new_id)
349 .await?;
350
351 Ok((
352 axum::http::StatusCode::CREATED,
353 Json(WorkflowResponse {
354 workflow_id: wf.id,
355 run_id: wf.run_id,
356 status: wf.status,
357 }),
358 ))
359}
360
361#[utoipa::path(
371 get, path = "/api/v1/workflows/{id}/state",
372 tag = "workflows",
373 params(("id" = String, Path, description = "Workflow ID")),
374 responses(
375 (status = 200, description = "Latest state snapshot"),
376 (status = 404, description = "No snapshot recorded for this workflow"),
377 ),
378)]
379pub async fn get_workflow_state<S: WorkflowStore>(
380 State(state): State<Arc<WorkflowCtx<S>>>,
381 Path(id): Path<String>,
382) -> Result<Json<serde_json::Value>, AppError> {
383 let snapshot = state
384 .get_latest_snapshot(&id)
385 .await?
386 .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
387
388 let parsed: serde_json::Value =
389 serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
390
391 Ok(Json(serde_json::json!({
392 "state": parsed,
393 "event_seq": snapshot.event_seq,
394 "created_at": snapshot.created_at,
395 })))
396}
397
398#[utoipa::path(
403 get, path = "/api/v1/workflows/{id}/state/{name}",
404 tag = "workflows",
405 params(
406 ("id" = String, Path, description = "Workflow ID"),
407 ("name" = String, Path, description = "Query handler name"),
408 ),
409 responses(
410 (status = 200, description = "Query value"),
411 (status = 404, description = "No snapshot or key not present"),
412 ),
413)]
414pub async fn get_workflow_state_by_name<S: WorkflowStore>(
415 State(state): State<Arc<WorkflowCtx<S>>>,
416 Path((id, name)): Path<(String, String)>,
417) -> Result<Json<serde_json::Value>, AppError> {
418 let snapshot = state
419 .get_latest_snapshot(&id)
420 .await?
421 .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
422
423 let parsed: serde_json::Value =
424 serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
425
426 let value = parsed
427 .get(&name)
428 .cloned()
429 .ok_or_else(|| AppError::NotFound(format!("query '{name}' for workflow {id}")))?;
430
431 Ok(Json(serde_json::json!({
432 "value": value,
433 "event_seq": snapshot.event_seq,
434 "created_at": snapshot.created_at,
435 })))
436}
437
438pub enum AppError {
441 Internal(anyhow::Error),
442 NotFound(String),
443}
444
445impl From<anyhow::Error> for AppError {
446 fn from(e: anyhow::Error) -> Self {
447 Self::Internal(e)
448 }
449}
450
451impl From<serde_json::Error> for AppError {
452 fn from(e: serde_json::Error) -> Self {
453 Self::Internal(e.into())
454 }
455}
456
457impl axum::response::IntoResponse for AppError {
458 fn into_response(self) -> axum::response::Response {
459 match self {
460 Self::Internal(e) => {
461 tracing::error!("Internal error: {e}");
462 (
463 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
464 Json(serde_json::json!({ "error": e.to_string() })),
465 )
466 .into_response()
467 }
468 Self::NotFound(msg) => (
469 axum::http::StatusCode::NOT_FOUND,
470 Json(serde_json::json!({ "error": format!("not found: {msg}") })),
471 )
472 .into_response(),
473 }
474 }
475}
476
477use crate::types::{WorkflowEvent, WorkflowRecord};