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