ironflow_core/operation/mod.rs
1//! [`Operation`] trait and [`OperationContext`] for user-defined step operations.
2//!
3//! This module provides the extension point for custom step types that integrate
4//! into the workflow lifecycle. Common use cases include API clients (GitLab,
5//! Gmail, Slack) that need full step tracking.
6//!
7//! # How it works
8//!
9//! 1. Implement [`Operation`] on your type.
10//! 2. Call `WorkflowContext::operation()` inside a `WorkflowHandler`.
11//! 3. The engine handles the full step lifecycle: create step record, transition
12//! to Running, execute, persist output/duration, mark Completed or Failed.
13//!
14//! # OperationContext
15//!
16//! [`OperationContext`] is passed to every [`Operation::execute`] call. It
17//! provides a shared [`reqwest::Client`] and a [`SecretResolver`] so that
18//! operations do not need to create their own HTTP clients or manage
19//! credentials manually.
20//!
21//! # Examples
22//!
23//! ```no_run
24//! use async_trait::async_trait;
25//! use ironflow_core::operation::{Operation, OperationContext};
26//! use ironflow_core::error::OperationError;
27//! use serde_json::{Value, json};
28//!
29//! struct CreateGitlabIssue {
30//! project_id: u64,
31//! title: String,
32//! }
33//!
34//! #[async_trait]
35//! impl Operation for CreateGitlabIssue {
36//! fn kind(&self) -> &str {
37//! "gitlab"
38//! }
39//!
40//! async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
41//! // Use ctx.http_client() for HTTP calls, ctx.secrets() for credentials.
42//! Ok(json!({"issue_id": 42, "url": "https://gitlab.com/issues/42"}))
43//! }
44//! }
45//! ```
46
47#[cfg(test)]
48mod tests;
49
50use std::fmt;
51use std::sync::Arc;
52
53use async_trait::async_trait;
54use reqwest::Client;
55use serde::de::DeserializeOwned;
56use serde_json::Value;
57
58use crate::error::OperationError;
59
60/// A decrypted secret value returned by [`SecretResolver::get`].
61///
62/// Wraps a plaintext string. The value is only available after successful
63/// decryption by the underlying store.
64///
65/// # Examples
66///
67/// ```
68/// use ironflow_core::operation::SecretValue;
69///
70/// let secret = SecretValue { value: "sk-ant-12345".to_string() };
71/// assert_eq!(secret.value, "sk-ant-12345");
72/// ```
73#[derive(Clone)]
74pub struct SecretValue {
75 /// The decrypted plaintext value.
76 pub value: String,
77}
78
79impl fmt::Debug for SecretValue {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 f.debug_struct("SecretValue")
82 .field("value", &"[REDACTED]")
83 .finish()
84 }
85}
86
87/// Trait for resolving secrets at operation execution time.
88///
89/// Implementations provide read-only access to encrypted secrets scoped
90/// to the current workflow. The engine passes a resolver through
91/// [`OperationContext`] so that operations can fetch credentials without
92/// depending on the store crate.
93///
94/// # Examples
95///
96/// ```
97/// use ironflow_core::operation::{SecretResolver, NoopSecretResolver};
98///
99/// # tokio_test::block_on(async {
100/// let resolver = NoopSecretResolver;
101/// let result = resolver.get("any_key").await;
102/// assert!(result.unwrap().is_none());
103/// # });
104/// ```
105#[async_trait]
106pub trait SecretResolver: Send + Sync {
107 /// Look up a secret by key.
108 ///
109 /// Returns `Ok(Some(value))` if the secret exists, `Ok(None)` if it
110 /// does not, or `Err` on storage/decryption failure.
111 ///
112 /// # Errors
113 ///
114 /// Returns [`OperationError::Secret`] if the underlying store fails.
115 async fn get(&self, key: &str) -> Result<Option<SecretValue>, OperationError>;
116}
117
118/// A [`SecretResolver`] that always returns `Ok(None)`.
119///
120/// Used in tests and when the `secret-store` feature is disabled.
121///
122/// # Examples
123///
124/// ```
125/// use ironflow_core::operation::{SecretResolver, NoopSecretResolver};
126///
127/// # tokio_test::block_on(async {
128/// let resolver = NoopSecretResolver;
129/// assert!(resolver.get("anything").await.unwrap().is_none());
130/// # });
131/// ```
132pub struct NoopSecretResolver;
133
134#[async_trait]
135impl SecretResolver for NoopSecretResolver {
136 async fn get(&self, _key: &str) -> Result<Option<SecretValue>, OperationError> {
137 Ok(None)
138 }
139}
140
141/// Context provided to every [`Operation::execute`] call.
142///
143/// Carries a shared HTTP client and a secret resolver so that operations
144/// do not need to manage their own connections or credentials.
145///
146/// # Examples
147///
148/// ```
149/// use ironflow_core::operation::{OperationContext, NoopSecretResolver};
150/// use std::sync::Arc;
151///
152/// let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
153/// let _client = ctx.http_client();
154/// ```
155pub struct OperationContext {
156 http_client: Client,
157 secrets: Arc<dyn SecretResolver>,
158}
159
160impl OperationContext {
161 /// Create a new context with a default HTTP client.
162 ///
163 /// # Examples
164 ///
165 /// ```
166 /// use ironflow_core::operation::{OperationContext, NoopSecretResolver};
167 /// use std::sync::Arc;
168 ///
169 /// let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
170 /// ```
171 pub fn new(secrets: Arc<dyn SecretResolver>) -> Self {
172 Self {
173 http_client: Client::new(),
174 secrets,
175 }
176 }
177
178 /// Create a new context with a custom HTTP client.
179 ///
180 /// Use this to share a single [`Client`] across multiple operations
181 /// within the same workflow run.
182 ///
183 /// # Examples
184 ///
185 /// ```
186 /// use ironflow_core::operation::{OperationContext, NoopSecretResolver};
187 /// use reqwest::Client;
188 /// use std::sync::Arc;
189 ///
190 /// let client = Client::new();
191 /// let ctx = OperationContext::with_http_client(client, Arc::new(NoopSecretResolver));
192 /// ```
193 pub fn with_http_client(http_client: Client, secrets: Arc<dyn SecretResolver>) -> Self {
194 Self {
195 http_client,
196 secrets,
197 }
198 }
199
200 /// The shared HTTP client for this operation context.
201 pub fn http_client(&self) -> &Client {
202 &self.http_client
203 }
204
205 /// The secret resolver for this operation context.
206 pub fn secrets(&self) -> &dyn SecretResolver {
207 &*self.secrets
208 }
209}
210
211/// A user-defined operation that integrates into the workflow step lifecycle.
212///
213/// Implement this trait for custom integrations (GitLab, Gmail, Slack, etc.)
214/// that need full step tracking when executed via `WorkflowContext::operation()`.
215///
216/// # Contract
217///
218/// - [`kind()`](Operation::kind) returns a short, lowercase identifier stored
219/// as `StepKind::Custom` in the database (e.g. `"gitlab"`, `"gmail"`, `"slack"`).
220/// - [`execute()`](Operation::execute) performs the operation and returns
221/// a JSON [`Value`] on success. The engine persists this as the step output.
222///
223/// # Examples
224///
225/// ```no_run
226/// use async_trait::async_trait;
227/// use ironflow_core::operation::{Operation, OperationContext};
228/// use ironflow_core::error::OperationError;
229/// use serde_json::{Value, json};
230///
231/// struct SendSlackMessage {
232/// channel: String,
233/// text: String,
234/// }
235///
236/// #[async_trait]
237/// impl Operation for SendSlackMessage {
238/// fn kind(&self) -> &str { "slack" }
239///
240/// async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError> {
241/// // Post to Slack API using ctx.http_client() ...
242/// Ok(json!({"ok": true, "ts": "1234567890.123456"}))
243/// }
244/// }
245/// ```
246#[async_trait]
247pub trait Operation: Send + Sync {
248 /// A short, lowercase identifier for this operation type.
249 ///
250 /// Stored as `StepKind::Custom(kind)` in the database.
251 /// Examples: `"gitlab"`, `"gmail"`, `"slack"`.
252 fn kind(&self) -> &str;
253
254 /// Execute the operation and return the result as JSON.
255 ///
256 /// The returned [`Value`] is persisted as the step output. On error,
257 /// the engine marks the step as Failed and records the error message.
258 ///
259 /// # Errors
260 ///
261 /// Return [`OperationError`] if the operation fails.
262 async fn execute(&self, ctx: &OperationContext) -> Result<Value, OperationError>;
263
264 /// Optional JSON representation of the operation input, stored in
265 /// the step's `input` column for observability.
266 ///
267 /// Defaults to [`None`]. Override to provide structured input logging.
268 fn input(&self) -> Option<Value> {
269 None
270 }
271}
272
273/// A typed extension of [`Operation`] that declares a concrete output type.
274///
275/// Implement this alongside [`Operation`] when the step output has a known
276/// Rust type. Consumers can then deserialize the output without guessing
277/// the shape.
278///
279/// # Examples
280///
281/// ```no_run
282/// use async_trait::async_trait;
283/// use ironflow_core::operation::{Operation, OperationContext, TypedOperation};
284/// use ironflow_core::error::OperationError;
285/// use serde::Deserialize;
286/// use serde_json::{Value, json};
287///
288/// #[derive(Debug, Deserialize)]
289/// struct IssueCreated {
290/// iid: u64,
291/// url: String,
292/// }
293///
294/// struct CreateIssue;
295///
296/// #[async_trait]
297/// impl Operation for CreateIssue {
298/// fn kind(&self) -> &str { "gitlab" }
299/// async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
300/// Ok(json!({"iid": 42, "url": "https://gitlab.com/issues/42"}))
301/// }
302/// }
303///
304/// impl TypedOperation for CreateIssue {
305/// type Output = IssueCreated;
306/// }
307/// ```
308pub trait TypedOperation: Operation {
309 /// The concrete output type that [`Operation::execute`] produces.
310 type Output: DeserializeOwned;
311}