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}