Skip to main content

ironflow_engine/
operation.rs

1//! [`Operation`] trait — user-defined step operations.
2//!
3//! Implement this trait to create custom step types that integrate into the
4//! workflow lifecycle. Common use cases include API clients (GitLab, Gmail,
5//! Slack) that need full step tracking (persistence, duration, error handling).
6//!
7//! # How it works
8//!
9//! 1. Implement [`Operation`] on your type.
10//! 2. Call [`WorkflowContext::operation()`](crate::context::WorkflowContext::operation)
11//!    inside a [`WorkflowHandler`](crate::handler::WorkflowHandler).
12//! 3. The engine handles the full step lifecycle: create step record, transition
13//!    to Running, execute, persist output/duration, mark Completed or Failed.
14//!
15//! # Examples
16//!
17//! ```no_run
18//! use ironflow_engine::operation::Operation;
19//! use ironflow_engine::error::EngineError;
20//! use serde_json::{Value, json};
21//! use std::future::Future;
22//! use std::pin::Pin;
23//!
24//! struct CreateGitlabIssue {
25//!     project_id: u64,
26//!     title: String,
27//! }
28//!
29//! impl Operation for CreateGitlabIssue {
30//!     fn kind(&self) -> &str {
31//!         "gitlab"
32//!     }
33//!
34//!     fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
35//!         Box::pin(async move {
36//!             // Call the GitLab API here (e.g. via the `gitlab` crate).
37//!             Ok(json!({"issue_id": 42, "url": "https://gitlab.com/issues/42"}))
38//!         })
39//!     }
40//! }
41//! ```
42
43use std::future::Future;
44use std::pin::Pin;
45
46use serde_json::Value;
47
48use crate::error::EngineError;
49
50/// A user-defined operation that integrates into the workflow step lifecycle.
51///
52/// Implement this trait for custom integrations (GitLab, Gmail, Slack, etc.)
53/// that need full step tracking when executed via
54/// [`WorkflowContext::operation()`](crate::context::WorkflowContext::operation).
55///
56/// # Contract
57///
58/// - [`kind()`](Operation::kind) returns a short, lowercase identifier stored
59///   as [`StepKind::Custom`](ironflow_store::entities::StepKind::Custom) in
60///   the database (e.g. `"gitlab"`, `"gmail"`, `"slack"`).
61/// - [`execute()`](Operation::execute) performs the operation and returns
62///   a JSON [`Value`] on success. The engine persists this as the step output.
63///
64/// # Examples
65///
66/// ```no_run
67/// use ironflow_engine::operation::Operation;
68/// use ironflow_engine::error::EngineError;
69/// use serde_json::{Value, json};
70/// use std::future::Future;
71/// use std::pin::Pin;
72///
73/// struct SendSlackMessage {
74///     channel: String,
75///     text: String,
76/// }
77///
78/// impl Operation for SendSlackMessage {
79///     fn kind(&self) -> &str { "slack" }
80///
81///     fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
82///         Box::pin(async move {
83///             // Post to Slack API ...
84///             Ok(json!({"ok": true, "ts": "1234567890.123456"}))
85///         })
86///     }
87/// }
88/// ```
89pub trait Operation: Send + Sync {
90    /// A short, lowercase identifier for this operation type.
91    ///
92    /// Stored as [`StepKind::Custom(kind)`](ironflow_store::entities::StepKind::Custom)
93    /// in the database. Examples: `"gitlab"`, `"gmail"`, `"slack"`.
94    fn kind(&self) -> &str;
95
96    /// Execute the operation and return the result as JSON.
97    ///
98    /// The returned [`Value`] is persisted as the step output. On error,
99    /// the engine marks the step as Failed and records the error message.
100    ///
101    /// # Errors
102    ///
103    /// Return [`EngineError`] if the operation fails.
104    fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>>;
105
106    /// Optional JSON representation of the operation input, stored in
107    /// the step's `input` column for observability.
108    ///
109    /// Defaults to [`None`]. Override to provide structured input logging.
110    ///
111    /// # Examples
112    ///
113    /// ```no_run
114    /// # use ironflow_engine::operation::Operation;
115    /// # use ironflow_engine::error::EngineError;
116    /// # use serde_json::{Value, json};
117    /// # use std::pin::Pin;
118    /// # use std::future::Future;
119    /// # struct MyOp;
120    /// # impl Operation for MyOp {
121    /// #     fn kind(&self) -> &str { "test" }
122    /// #     fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
123    /// #         Box::pin(async { Ok(json!({})) })
124    /// #     }
125    /// fn input(&self) -> Option<Value> {
126    ///     Some(json!({"project_id": 123, "title": "Bug report"}))
127    /// }
128    /// # }
129    /// ```
130    fn input(&self) -> Option<Value> {
131        None
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use ironflow_core::error::OperationError;
139    use serde_json::json;
140
141    struct GitLabIssueOp {
142        project_id: u64,
143        title: String,
144    }
145
146    impl Operation for GitLabIssueOp {
147        fn kind(&self) -> &str {
148            "gitlab"
149        }
150
151        fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
152            Box::pin(async move {
153                Ok(json!({
154                    "issue_id": 42,
155                    "url": "https://gitlab.com/issues/42",
156                    "project_id": self.project_id,
157                    "title": self.title
158                }))
159            })
160        }
161
162        fn input(&self) -> Option<Value> {
163            Some(json!({
164                "project_id": self.project_id,
165                "title": self.title
166            }))
167        }
168    }
169
170    struct NoInputOp;
171
172    impl Operation for NoInputOp {
173        fn kind(&self) -> &str {
174            "noop"
175        }
176
177        fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
178            Box::pin(async { Ok(json!({"status": "ok"})) })
179        }
180    }
181
182    struct ErrorOp;
183
184    impl Operation for ErrorOp {
185        fn kind(&self) -> &str {
186            "error_test"
187        }
188
189        fn execute(&self) -> Pin<Box<dyn Future<Output = Result<Value, EngineError>> + Send + '_>> {
190            Box::pin(async {
191                Err(EngineError::Operation(OperationError::Http {
192                    status: Some(500),
193                    message: "test error".to_string(),
194                }))
195            })
196        }
197    }
198
199    #[test]
200    fn operation_kind_identifies_operation_type() {
201        let op = GitLabIssueOp {
202            project_id: 123,
203            title: "Bug".to_string(),
204        };
205        assert_eq!(op.kind(), "gitlab");
206    }
207
208    #[test]
209    fn operation_with_input_provides_structured_logging() {
210        let op = GitLabIssueOp {
211            project_id: 456,
212            title: "Feature request".to_string(),
213        };
214        let input = op.input();
215        assert!(input.is_some());
216
217        let input_value = input.unwrap();
218        assert_eq!(input_value["project_id"], 456);
219        assert_eq!(input_value["title"], "Feature request");
220    }
221
222    #[test]
223    fn operation_without_input_returns_none() {
224        let op = NoInputOp;
225        assert_eq!(op.input(), None);
226    }
227
228    #[tokio::test]
229    async fn operation_execute_returns_json_output() {
230        let op = GitLabIssueOp {
231            project_id: 789,
232            title: "Test".to_string(),
233        };
234        let result = op.execute().await;
235        assert!(result.is_ok());
236
237        let output = result.unwrap();
238        assert_eq!(output["issue_id"], 42);
239        assert_eq!(output["project_id"], 789);
240        assert_eq!(output["title"], "Test");
241    }
242
243    #[tokio::test]
244    async fn operation_execute_can_return_error() {
245        let op = ErrorOp;
246        let result = op.execute().await;
247        assert!(result.is_err());
248    }
249
250    #[test]
251    fn operation_kind_identifies_different_types() {
252        let gitlab = GitLabIssueOp {
253            project_id: 1,
254            title: "a".to_string(),
255        };
256        let noop = NoInputOp;
257        let error = ErrorOp;
258
259        assert_eq!(gitlab.kind(), "gitlab");
260        assert_eq!(noop.kind(), "noop");
261        assert_eq!(error.kind(), "error_test");
262    }
263
264    #[tokio::test]
265    async fn no_input_operation_executes_successfully() {
266        let op = NoInputOp;
267        let result = op.execute().await;
268        assert!(result.is_ok());
269
270        let output = result.unwrap();
271        assert_eq!(output["status"], "ok");
272    }
273
274    #[test]
275    fn gitlab_operation_input_has_all_fields() {
276        let op = GitLabIssueOp {
277            project_id: 999,
278            title: "Complete feature".to_string(),
279        };
280        let input = op.input().expect("input present");
281
282        assert!(input.is_object());
283        assert!(input.get("project_id").is_some());
284        assert!(input.get("title").is_some());
285    }
286}