Skip to main content

arc_web/
lib.rs

1//! Arc framework runtime.
2//!
3//! Holds the reusable web/runtime machinery — Actix middleware, Tera helpers,
4//! the event-sourced stack wiring, the websocket transport, and the server
5//! bootstrap — behind a builder seam. Applications depend on this crate by
6//! version and plug their own aggregate, projectors, and routes in through
7//! [`ArcApp::builder`]. No concrete domain type lives here: the runtime is
8//! generic over [`arc_core::aggregate::Aggregate`].
9
10use std::any::TypeId;
11use std::future::Future;
12use std::path::{Path, PathBuf};
13use std::pin::Pin;
14use std::sync::{Arc, Mutex};
15use std::{env, fs};
16
17use actix_web::web::{self, ServiceConfig};
18use arc_core::aggregate::Aggregate;
19use arc_core::command_bus::SnapshotPolicy;
20use arc_core::projection::Projector;
21use tracing::{debug, error, info};
22
23pub mod helpers {
24    pub mod access_log;
25    pub mod audit_context;
26    pub mod config;
27    pub mod csrf;
28    pub mod database;
29    pub mod es_stack;
30    pub mod general;
31    pub mod jwt;
32    pub mod rate_limit;
33    pub mod session;
34    pub mod template;
35}
36
37pub mod http {
38    pub mod middlewares {
39        pub mod auth_middleware;
40        pub mod idle_timeout_middleware;
41        pub mod jwt_middleware;
42        pub mod rate_limit_middleware;
43    }
44    pub mod errors;
45}
46
47pub mod commands;
48pub mod websocket;
49
50/// Shared application state accessible by request handlers via `web::Data`.
51#[derive(Debug)]
52pub struct AppState {
53    pub app_name: Mutex<String>,
54}
55
56/// A read-model projector plus the view (table) it maintains. Applications
57/// register these against their aggregate so the framework can drive
58/// synchronous, read-after-write projections in single-process mode.
59pub struct ProjectorReg {
60    pub projector: Box<dyn Projector>,
61    pub view: String,
62}
63
64impl ProjectorReg {
65    pub fn new(projector: impl Projector + 'static, view: impl Into<String>) -> Self {
66        Self {
67            projector: Box::new(projector),
68            view: view.into(),
69        }
70    }
71}
72
73/// Entry point to the framework. Applications register one or more aggregate
74/// types, their projectors, and routes, then drive the result with
75/// [`ArcAppBuilder::serve`].
76pub struct ArcApp;
77
78impl ArcApp {
79    pub fn builder() -> ArcAppBuilder {
80        ArcAppBuilder {
81            aggregates: Vec::new(),
82            routes: Vec::new(),
83            app_data: Vec::new(),
84            plugin_names: Vec::new(),
85            plugins: Vec::new(),
86        }
87    }
88}
89
90type RoutesFn = dyn Fn(&mut ServiceConfig) + Send + Sync + 'static;
91type AppDataFn = dyn Fn(&mut ServiceConfig) + Send + Sync + 'static;
92type AggregateBuildFuture = Pin<
93    Box<
94        dyn Future<Output = std::io::Result<commands::serve::BuiltAggregateRuntime>>
95            + Send
96            + 'static,
97    >,
98>;
99type AggregateBuildFn =
100    dyn FnOnce(Vec<ProjectorReg>, Option<SnapshotPolicy>) -> AggregateBuildFuture + Send + 'static;
101
102struct AggregateRegistration {
103    type_id: TypeId,
104    aggregate_type: &'static str,
105    projectors: Vec<ProjectorReg>,
106    snapshot_policy: Option<SnapshotPolicy>,
107    build: Box<AggregateBuildFn>,
108}
109
110impl AggregateRegistration {
111    fn new<A: Aggregate + 'static>() -> Self {
112        Self {
113            type_id: TypeId::of::<A>(),
114            aggregate_type: A::aggregate_type(),
115            projectors: Vec::new(),
116            snapshot_policy: None,
117            build: Box::new(|projectors, snapshot_policy| {
118                Box::pin(commands::serve::build_aggregate_runtime::<A>(
119                    projectors,
120                    snapshot_policy,
121                ))
122            }),
123        }
124    }
125}
126
127/// Application builder holding type-erased registrations for every aggregate
128/// served by the process. Each registration produces its own typed
129/// `CommandBus<A>` while sharing the configured storage backend.
130pub struct ArcAppBuilder {
131    aggregates: Vec<AggregateRegistration>,
132    routes: Vec<Arc<RoutesFn>>,
133    app_data: Vec<Arc<AppDataFn>>,
134    plugin_names: Vec<&'static str>,
135    plugins: Vec<Arc<dyn ArcPlugin>>,
136}
137
138/// Setup-time inputs shared by installed capability packages.
139pub struct PluginSetupContext<'a> {
140    pub database_url: &'a str,
141    pub project_root: &'a Path,
142}
143
144/// Compile-time extension package registered through [`ArcAppBuilder`].
145///
146/// Plugins remain ordinary Cargo dependencies: registration is explicit,
147/// typed, and visible in application code. Implementations may add routes,
148/// aggregates, projectors, and other builder-supported capabilities.
149#[async_trait::async_trait]
150pub trait ArcPlugin: Send + Sync + 'static {
151    /// Stable identifier used for collision detection and diagnostics.
152    fn name(&self) -> &'static str;
153
154    /// Contribute this package's capabilities to the application builder.
155    fn register(&self, builder: ArcAppBuilder) -> ArcAppBuilder;
156
157    /// Run package-owned migrations and idempotent setup work.
158    async fn setup(&self, _context: &PluginSetupContext<'_>) -> std::io::Result<()> {
159        Ok(())
160    }
161}
162
163impl ArcAppBuilder {
164    /// Register a writable aggregate type. Arc creates and injects a distinct
165    /// `CommandBus<A>` for every call.
166    pub fn register_aggregate<A: Aggregate + 'static>(mut self) -> Self {
167        assert!(
168            !self
169                .aggregates
170                .iter()
171                .any(|registration| registration.type_id == TypeId::of::<A>()),
172            "aggregate type {} is already registered",
173            A::aggregate_type()
174        );
175        self.aggregates.push(AggregateRegistration::new::<A>());
176        self
177    }
178
179    /// Register all projectors for the most recently registered aggregate.
180    pub fn register_projectors(mut self, projectors: Vec<ProjectorReg>) -> Self {
181        self.current_aggregate_mut().projectors.extend(projectors);
182        self
183    }
184
185    /// Register a single projector for the most recently registered aggregate.
186    pub fn register_projector(
187        mut self,
188        projector: impl Projector + 'static,
189        view: impl Into<String>,
190    ) -> Self {
191        self.current_aggregate_mut()
192            .projectors
193            .push(ProjectorReg::new(projector, view));
194        self
195    }
196
197    /// Mount the application's own routes. Framework handlers (static assets,
198    /// websocket, health) are exported from this crate for the closure to
199    /// reference; the application owns which routes it mounts.
200    pub fn register_routes<F>(mut self, f: F) -> Self
201    where
202        F: Fn(&mut ServiceConfig) + Send + Sync + 'static,
203    {
204        self.routes.push(Arc::new(f));
205        self
206    }
207
208    /// Inject shared typed state contributed by an application or plugin.
209    pub fn register_data<T>(mut self, data: Arc<T>) -> Self
210    where
211        T: ?Sized + Send + Sync + 'static,
212    {
213        let data = web::Data::from(data);
214        self.app_data.push(Arc::new(move |cfg| {
215            cfg.app_data(data.clone());
216        }));
217        self
218    }
219
220    /// Register a compile-time capability package.
221    pub fn register_plugin<P: ArcPlugin>(mut self, plugin: P) -> Self {
222        let name = plugin.name();
223        assert!(
224            !self.plugin_names.contains(&name),
225            "plugin {name} is already registered"
226        );
227        self.plugin_names.push(name);
228        let plugin = Arc::new(plugin);
229        let mut builder = plugin.register(self);
230        builder.plugins.push(plugin);
231        builder
232    }
233
234    /// Run setup hooks for every registered capability package in order.
235    pub async fn setup_plugins(&self, context: &PluginSetupContext<'_>) -> std::io::Result<()> {
236        for plugin in &self.plugins {
237            plugin.setup(context).await.map_err(|error| {
238                std::io::Error::new(
239                    error.kind(),
240                    format!("plugin {} setup failed: {error}", plugin.name()),
241                )
242            })?;
243        }
244        Ok(())
245    }
246
247    /// Snapshot policy for the most recently registered aggregate.
248    pub fn snapshot_policy(mut self, policy: Option<SnapshotPolicy>) -> Self {
249        self.current_aggregate_mut().snapshot_policy = policy;
250        self
251    }
252
253    fn current_aggregate_mut(&mut self) -> &mut AggregateRegistration {
254        self.aggregates.last_mut().unwrap_or_else(|| {
255            panic!(
256                "register_aggregate::<A>() must be called before registering projectors or a snapshot policy"
257            )
258        })
259    }
260
261    /// Boot the HTTP server with every registered aggregate and the
262    /// application's routes.
263    pub async fn serve(self, app_url: String, app_port: u16) -> std::io::Result<()> {
264        if self.aggregates.is_empty() {
265            return Err(std::io::Error::new(
266                std::io::ErrorKind::InvalidInput,
267                "ArcAppBuilder::serve requires at least one register_aggregate::<A>() call",
268            ));
269        }
270        if self.routes.is_empty() {
271            return Err(std::io::Error::new(
272                std::io::ErrorKind::InvalidInput,
273                "ArcAppBuilder::serve requires at least one route registration",
274            ));
275        }
276        commands::serve::run(
277            app_url,
278            app_port,
279            self.aggregates,
280            self.routes,
281            self.app_data,
282        )
283        .await
284    }
285}
286
287/// Verifies database availability for the configured driver. For file-backed
288/// drivers (SQLite) this checks the `DATABASE_URL` file exists and exits with
289/// code 1 if missing. Connection-string drivers (Postgres) skip the check.
290pub fn check_database_health() {
291    info!("Checking database health");
292    let driver = helpers::config::DatabaseDriver::from_env();
293
294    if !driver.is_file_backed() {
295        debug!(
296            driver = driver.as_str(),
297            "Database driver uses a connection string; skipping filesystem check"
298        );
299        return;
300    }
301
302    let database: String = helpers::config::database_url();
303    if !fs::exists(PathBuf::from(&database)).unwrap() {
304        error!("Database file not found at: {}", database);
305        error!("Please run `cargo run migrate` to create the database");
306        std::process::exit(1);
307    }
308    debug!("Database file found at: {}", database);
309}
310
311/// Copies `.env.example` to `.env` when no `.env` file is present.
312pub fn check_app_health() {
313    info!("Checking app health");
314    if !fs::exists(PathBuf::from(".env")).unwrap() {
315        info!("Creating .env file from .env.example");
316        fs::copy(PathBuf::from(".env.example"), PathBuf::from(".env"))
317            .expect("Failed to copy .env.example to .env");
318    }
319}
320
321/// Fails fast if required environment variables are missing.
322pub fn validate_environment() {
323    let required_vars = ["APP_URL", "SECRET_KEY", "DATABASE_URL"];
324    let mut missing = Vec::new();
325    for var in required_vars {
326        if env::var(var).is_err() {
327            missing.push(var);
328        }
329    }
330    if !missing.is_empty() {
331        error!(
332            "Missing required environment variables: {}. Check your .env file.",
333            missing.join(", ")
334        );
335        std::process::exit(1);
336    }
337    debug!("All required environment variables present");
338}