ironflow_api/state.rs
1//! Application state and dependency injection.
2//!
3//! [`AppState`] holds the shared [`Store`] and [`Engine`] used by all handlers.
4
5use std::sync::Arc;
6#[cfg(feature = "prometheus")]
7use std::sync::OnceLock;
8
9use axum::extract::FromRef;
10#[cfg(feature = "prometheus")]
11use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
12use tokio::sync::broadcast;
13use uuid::Uuid;
14
15use ironflow_artifacts::blob_store::BlobStore;
16use ironflow_auth::jwt::JwtConfig;
17use ironflow_engine::engine::Engine;
18use ironflow_engine::notify::{Event, WorkflowEventBus};
19use ironflow_store::entities::Run;
20use ironflow_store::store::Store;
21
22use crate::error::ApiError;
23
24/// Global application state.
25///
26/// Holds the shared store (runs, users, API keys, secrets) and engine,
27/// extracted by handlers using Axum's state extraction mechanism.
28///
29/// # Examples
30///
31/// ```no_run
32/// use ironflow_api::state::AppState;
33/// use ironflow_auth::jwt::JwtConfig;
34/// use ironflow_store::prelude::*;
35/// use ironflow_store::store::Store;
36/// use ironflow_engine::engine::Engine;
37/// use ironflow_core::providers::claude::ClaudeCodeProvider;
38/// use std::sync::Arc;
39///
40/// # async fn example() {
41/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
42/// let provider = Arc::new(ClaudeCodeProvider::new());
43/// let engine = Arc::new(Engine::new(store.clone(), provider));
44/// let jwt_config = Arc::new(JwtConfig {
45/// secret: "secret".to_string(),
46/// access_token_ttl_secs: 900,
47/// refresh_token_ttl_secs: 604800,
48/// cookie_domain: None,
49/// cookie_secure: false,
50/// });
51/// let broadcaster = ironflow_api::sse::SseBroadcaster::new();
52/// let state = AppState::new(store, engine, jwt_config, "token".to_string(), broadcaster.sender());
53/// # }
54/// ```
55#[derive(Clone)]
56pub struct AppState {
57 /// The unified backing store for runs, steps, users, API keys, and secrets.
58 pub store: Arc<dyn Store>,
59 /// The workflow orchestration engine.
60 pub engine: Arc<Engine>,
61 /// JWT configuration for auth tokens.
62 pub jwt_config: Arc<JwtConfig>,
63 /// Static token for worker-to-API authentication.
64 pub worker_token: String,
65 /// Broadcast sender for SSE event streaming.
66 pub event_sender: broadcast::Sender<Event>,
67 /// Per-run event bus for real-time workflow monitoring.
68 ///
69 /// When set, the `GET /api/v1/runs/{id}/events` route subscribes to this
70 /// bus and streams [`WorkflowEvent`](ironflow_engine::notify::WorkflowEvent)s
71 /// via SSE. `None` when the engine was not configured with a bus.
72 pub event_bus: Option<WorkflowEventBus>,
73 /// Where artifact bytes live, when artifacts are enabled.
74 ///
75 /// `None` on a deployment that has not configured artifact storage: the
76 /// artifact routes answer `501` and every other endpoint is unaffected.
77 pub blob_store: Option<Arc<dyn BlobStore>>,
78 /// Prometheus metrics handle (only when `prometheus` feature is enabled).
79 #[cfg(feature = "prometheus")]
80 pub prometheus_handle: PrometheusHandle,
81}
82
83impl FromRef<AppState> for Arc<dyn Store> {
84 fn from_ref(state: &AppState) -> Self {
85 Arc::clone(&state.store)
86 }
87}
88
89impl FromRef<AppState> for Arc<JwtConfig> {
90 fn from_ref(state: &AppState) -> Self {
91 Arc::clone(&state.jwt_config)
92 }
93}
94
95#[cfg(feature = "prometheus")]
96impl FromRef<AppState> for PrometheusHandle {
97 fn from_ref(state: &AppState) -> Self {
98 state.prometheus_handle.clone()
99 }
100}
101
102impl AppState {
103 /// Create a new `AppState`.
104 ///
105 /// When the `prometheus` feature is enabled, a global Prometheus recorder
106 /// is installed (once) and its handle is stored in the state.
107 ///
108 /// # Panics
109 ///
110 /// Panics if a Prometheus recorder cannot be installed (should only
111 /// happen if another incompatible recorder was set elsewhere).
112 pub fn new(
113 store: Arc<dyn Store>,
114 engine: Arc<Engine>,
115 jwt_config: Arc<JwtConfig>,
116 worker_token: String,
117 event_sender: broadcast::Sender<Event>,
118 ) -> Self {
119 Self {
120 store,
121 engine,
122 jwt_config,
123 worker_token,
124 event_sender,
125 event_bus: None,
126 blob_store: None,
127 #[cfg(feature = "prometheus")]
128 prometheus_handle: Self::global_prometheus_handle(),
129 }
130 }
131
132 /// Enable artifacts by attaching the backend that holds their bytes.
133 ///
134 /// # Examples
135 ///
136 /// ```no_run
137 /// use std::sync::Arc;
138 ///
139 /// use ironflow_api::state::AppState;
140 /// use ironflow_artifacts::blob_store::BlobStore;
141 /// use ironflow_artifacts::local::LocalBlobStore;
142 ///
143 /// # fn example(state: AppState) -> AppState {
144 /// let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new("/var/lib/ironflow/artifacts"));
145 /// state.with_blob_store(blob)
146 /// # }
147 /// ```
148 pub fn with_blob_store(mut self, blob_store: Arc<dyn BlobStore>) -> Self {
149 self.blob_store = Some(blob_store);
150 self
151 }
152
153 /// Attach a [`WorkflowEventBus`] for per-run SSE streaming.
154 ///
155 /// When set, `GET /api/v1/runs/{id}/events` streams step-level events
156 /// for a specific workflow run. When absent the route returns an empty
157 /// SSE stream (with keep-alive).
158 ///
159 /// # Examples
160 ///
161 /// ```no_run
162 /// use ironflow_api::state::AppState;
163 /// use ironflow_engine::notify::WorkflowEventBus;
164 ///
165 /// # fn example(state: AppState) -> AppState {
166 /// state.with_event_bus(WorkflowEventBus::new())
167 /// # }
168 /// ```
169 pub fn with_event_bus(mut self, bus: WorkflowEventBus) -> Self {
170 self.event_bus = Some(bus);
171 self
172 }
173
174 /// The artifact backend, or a `501` error when artifacts are disabled.
175 ///
176 /// # Errors
177 ///
178 /// Returns [`ApiError::ArtifactStorageUnavailable`] when no backend is attached.
179 pub fn blob_store_or_501(&self) -> Result<&Arc<dyn BlobStore>, ApiError> {
180 self.blob_store
181 .as_ref()
182 .ok_or(ApiError::ArtifactStorageUnavailable)
183 }
184
185 /// Install (or reuse) a global Prometheus recorder and return its handle.
186 #[cfg(feature = "prometheus")]
187 fn global_prometheus_handle() -> PrometheusHandle {
188 static HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
189 HANDLE
190 .get_or_init(|| {
191 PrometheusBuilder::new()
192 .install_recorder()
193 .expect("failed to install Prometheus recorder")
194 })
195 .clone()
196 }
197
198 /// Fetch a run by ID or return 404.
199 ///
200 /// # Errors
201 ///
202 /// Returns `ApiError::RunNotFound` if the run does not exist.
203 /// Returns `ApiError::Store` if there is a store error.
204 pub async fn get_run_or_404(&self, id: Uuid) -> Result<Run, ApiError> {
205 self.store
206 .get_run(id)
207 .await
208 .map_err(ApiError::from)?
209 .ok_or(ApiError::RunNotFound(id))
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use ironflow_core::providers::claude::ClaudeCodeProvider;
217 use ironflow_store::memory::InMemoryStore;
218 use ironflow_store::store::Store;
219
220 fn test_state() -> AppState {
221 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
222 let provider = Arc::new(ClaudeCodeProvider::new());
223 let engine = Arc::new(Engine::new(store.clone(), provider));
224 let jwt_config = Arc::new(JwtConfig {
225 secret: "test-secret".to_string(),
226 access_token_ttl_secs: 900,
227 refresh_token_ttl_secs: 604800,
228 cookie_domain: None,
229 cookie_secure: false,
230 });
231 let (event_sender, _) = broadcast::channel::<Event>(1);
232 AppState::new(
233 store,
234 engine,
235 jwt_config,
236 "test-worker-token".to_string(),
237 event_sender,
238 )
239 }
240
241 #[test]
242 fn app_state_cloneable() {
243 let state = test_state();
244 let _cloned = state.clone();
245 }
246
247 #[test]
248 fn app_state_from_ref() {
249 let state = test_state();
250 let extracted: Arc<dyn Store> = Arc::from_ref(&state);
251 assert!(Arc::ptr_eq(&extracted, &state.store));
252 }
253}