Skip to main content

coil/
lib.rs

1#![forbid(unsafe_code)]
2
3use coil_admin::AdminModule;
4use coil_cms::CmsModule;
5use coil_commerce::{CommerceModule, CommercePaymentsStripeModule};
6use coil_events::EventsModule;
7use coil_media::MediaModule;
8use coil_memberships::MembershipsModule;
9use coil_ops::OpsModule;
10use coil_runtime::{
11    RuntimeBuilder, customer_root_bootstrap_inputs_from_env,
12    customer_root_bootstrap_inputs_from_paths,
13};
14use std::env;
15use std::path::{Path, PathBuf};
16use thiserror::Error;
17
18pub use coil_admin as admin;
19pub use coil_app as app;
20pub use coil_app::CustomerAppManifest;
21pub use coil_auth as auth;
22pub use coil_auth::{AuthModelPackage, DefaultAuthModelPackage};
23pub use coil_cms as cms;
24pub use coil_commerce as commerce;
25pub use coil_config as config;
26pub use coil_config::{Environment, PlatformConfig};
27pub use coil_core::PlatformModule;
28pub use coil_customer_sdk as customer_sdk;
29pub use coil_customer_sdk::*;
30pub use coil_events as events;
31pub use coil_media as media;
32pub use coil_memberships as memberships;
33pub use coil_ops as ops;
34
35#[derive(Debug, Error)]
36pub enum CoilError {
37    #[error("unsupported official module `{module}`")]
38    UnsupportedOfficialModule { module: String },
39    #[error("failed to resolve the current working directory: {0}")]
40    CurrentDirectory(std::io::Error),
41    #[error("customer app manifest `{path}` could not be loaded: {reason}")]
42    ManifestLoad { path: PathBuf, reason: String },
43    #[error("platform config `{path}` could not be loaded: {reason}")]
44    ConfigLoad { path: PathBuf, reason: String },
45    #[error("customer runtime build failed: {reason}")]
46    RuntimeBuild { reason: String },
47    #[error("customer runtime bootstrap failed: {reason}")]
48    Bootstrap { reason: String },
49}
50
51pub const OFFICIAL_MODULE_NAMES: &[&str] = &[
52    "admin",
53    "cms",
54    "commerce",
55    "commerce-payments-stripe",
56    "events",
57    "media",
58    "memberships",
59    "ops",
60];
61
62pub mod modules {
63    use super::*;
64
65    pub fn admin() -> AdminModule {
66        AdminModule::new()
67    }
68
69    pub fn cms() -> CmsModule {
70        CmsModule::new()
71    }
72
73    pub fn commerce() -> CommerceModule {
74        CommerceModule::new()
75    }
76
77    pub fn commerce_payments_stripe() -> CommercePaymentsStripeModule {
78        CommercePaymentsStripeModule::new()
79    }
80
81    pub fn events() -> EventsModule {
82        EventsModule::new()
83    }
84
85    pub fn media() -> MediaModule {
86        MediaModule::new()
87    }
88
89    pub fn memberships() -> MembershipsModule {
90        MembershipsModule::new()
91    }
92
93    pub fn ops() -> OpsModule {
94        OpsModule::new()
95    }
96}
97
98#[derive(Default)]
99pub struct CoilBuilder {
100    customer_plugins: Vec<Box<dyn CustomerBackendPlugin>>,
101}
102
103pub fn builder() -> CoilBuilder {
104    CoilBuilder::default()
105}
106
107impl CoilBuilder {
108    pub fn with_customer_plugin<C>(mut self, plugin: C) -> Self
109    where
110        C: CustomerBackendPlugin,
111    {
112        self.customer_plugins.push(Box::new(plugin));
113        self
114    }
115
116    pub fn run_from_env(self) -> Result<(), CoilError> {
117        let app_root = env::current_dir().map_err(CoilError::CurrentDirectory)?;
118        let bootstrap = customer_root_bootstrap_inputs_from_env()
119            .map_err(|error| match error {
120                coil_runtime::RuntimeBootstrapError::ConfigLoad { path, reason } => {
121                    CoilError::ConfigLoad { path, reason }
122                }
123                coil_runtime::RuntimeBootstrapError::ConfigNotFound { app_root } => {
124                    CoilError::ConfigLoad {
125                        path: app_root.join("platform.toml"),
126                        reason:
127                            "set `COIL_CONFIG` or add `platform.toml` / `platform.dev.toml` to the customer app root"
128                                .to_string(),
129                    }
130                }
131                other => CoilError::RuntimeBuild {
132                    reason: other.to_string(),
133                },
134            })?;
135        self.run_from_paths(
136            app_root,
137            bootstrap.config_path,
138            env::var("COIL_BIND").ok(),
139        )
140    }
141
142    pub fn run_from_paths(
143        self,
144        app_root: impl AsRef<Path>,
145        config_path: impl AsRef<Path>,
146        bind_override: Option<String>,
147    ) -> Result<(), CoilError> {
148        let app_root = app_root.as_ref();
149        let manifest_path = app_root.join("app.toml");
150        let manifest =
151            coil_app::CustomerAppManifest::from_file(&manifest_path).map_err(|error| {
152                CoilError::ManifestLoad {
153                    path: manifest_path.clone(),
154                    reason: error.to_string(),
155                }
156            })?;
157
158        let bootstrap = customer_root_bootstrap_inputs_from_paths(
159            app_root,
160            config_path,
161        )
162        .map_err(|error| match error {
163            coil_runtime::RuntimeBootstrapError::ConfigLoad { path, reason } => {
164                CoilError::ConfigLoad { path, reason }
165            }
166            coil_runtime::RuntimeBootstrapError::ConfigNotFound { app_root } => {
167                CoilError::ConfigLoad {
168                    path: app_root.join("platform.toml"),
169                    reason:
170                        "set `COIL_CONFIG` or add `platform.toml` / `platform.dev.toml` to the customer app root"
171                            .to_string(),
172                }
173            }
174            other => CoilError::RuntimeBuild {
175                reason: other.to_string(),
176            },
177        })?;
178        let modules = official_modules_from_config(&bootstrap.config).map_err(|error| {
179            CoilError::RuntimeBuild {
180                reason: error.to_string(),
181            }
182        })?;
183        let runtime_plan = manifest
184            .build_customer_root_runtime_plan_with_extensions_and_customer_plugins_at(
185                bootstrap.config,
186                bootstrap.auth_package,
187                modules,
188                Vec::new(),
189                self.customer_plugins,
190                app_root,
191            )
192            .map_err(|error| CoilError::RuntimeBuild {
193                reason: error.to_string(),
194            })?;
195
196        runtime_plan
197            .runtime
198            .serve_from_env(bind_override)
199            .map_err(|error| CoilError::Bootstrap {
200                reason: error.to_string(),
201            })?;
202        Ok(())
203    }
204}
205
206pub fn with_official_modules<P>(builder: RuntimeBuilder<P>) -> RuntimeBuilder<P>
207where
208    P: AuthModelPackage + 'static,
209{
210    builder
211        .with_module(modules::admin())
212        .with_module(modules::cms())
213        .with_module(modules::commerce())
214        .with_module(modules::commerce_payments_stripe())
215        .with_module(modules::events())
216        .with_module(modules::media())
217        .with_module(modules::memberships())
218        .with_module(modules::ops())
219}
220
221pub trait RuntimeBuilderOfficialModulesExt<P> {
222    fn with_official_modules(self) -> Self;
223}
224
225impl<P> RuntimeBuilderOfficialModulesExt<P> for RuntimeBuilder<P>
226where
227    P: AuthModelPackage + 'static,
228{
229    fn with_official_modules(self) -> Self {
230        with_official_modules(self)
231    }
232}
233
234pub fn official_modules_from_config(
235    config: &PlatformConfig,
236) -> Result<Vec<Box<dyn PlatformModule>>, CoilError> {
237    official_modules_from_enabled(&config.modules.enabled)
238}
239
240pub fn official_modules_from_enabled(
241    enabled: &[String],
242) -> Result<Vec<Box<dyn PlatformModule>>, CoilError> {
243    let mut modules = Vec::with_capacity(enabled.len());
244    for module in enabled {
245        modules.push(official_module(module)?);
246    }
247    Ok(modules)
248}
249
250pub fn official_module(
251    module: impl AsRef<str>,
252) -> Result<Box<dyn PlatformModule>, CoilError> {
253    let module = module.as_ref();
254    let boxed: Box<dyn PlatformModule> = match module {
255        "admin" => Box::new(modules::admin()),
256        "commerce" => Box::new(modules::commerce()),
257        "commerce-payments-stripe" => Box::new(modules::commerce_payments_stripe()),
258        "cms" => Box::new(modules::cms()),
259        "events" => Box::new(modules::events()),
260        "media" => Box::new(modules::media()),
261        "memberships" => Box::new(modules::memberships()),
262        "ops" => Box::new(modules::ops()),
263        _ => {
264            return Err(CoilError::UnsupportedOfficialModule {
265                module: module.to_string(),
266            });
267        }
268    };
269    Ok(boxed)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    const VALID_CONFIG: &str = r#"
277[app]
278name = "customer-root-smoke"
279environment = "production"
280
281[server]
282bind = "0.0.0.0:8080"
283trusted_proxies = ["10.0.0.0/8"]
284
285[http.session]
286store = "redis"
287idle_timeout_secs = 3600
288absolute_timeout_secs = 86400
289
290[http.session_cookie]
291name = "coil_session"
292path = "/"
293same_site = "lax"
294secure = true
295http_only = true
296
297[http.flash_cookie]
298name = "coil_flash"
299path = "/"
300same_site = "lax"
301secure = true
302http_only = true
303
304[http.csrf]
305enabled = true
306field_name = "_csrf"
307header_name = "x-csrf-token"
308
309[tls]
310mode = "acme"
311challenge = "dns-01"
312provider = "cloudflare-dns"
313
314[storage]
315default_class = "public_upload"
316single_node_escape_hatch = "explicit_single_node"
317object_store = "s3"
318object_store_secret = { kind = "env", var = "OBJECT_STORE_URL" }
319local_root = "/tmp/coil-tests"
320deployment = "single_node"
321
322[cache]
323l1 = "moka"
324l2 = "redis"
325
326[i18n]
327default_locale = "en-GB"
328supported_locales = ["en-GB"]
329fallback_locale = "en-GB"
330localized_routes = true
331
332[seo]
333canonical_host = "www.example.com"
334emit_json_ld = true
335
336[auth]
337package = "coil-default-auth"
338explain_api = false
339tenant_id = 1
340
341[modules]
342enabled = ["admin", "cms", "commerce", "commerce-payments-stripe", "events", "media", "memberships", "ops"]
343
344[wasm]
345directory = "extensions"
346default_time_limit_ms = 50
347allow_network = false
348
349[jobs]
350backend = "redis"
351max_attempts = 10
352
353[observability]
354metrics = true
355tracing = true
356
357[assets]
358publish_manifest = true
359cdn_base_url = "https://cdn.example.com"
360"#;
361
362    #[test]
363    fn builder_links_full_official_distribution() {
364        let config = PlatformConfig::from_toml_str(VALID_CONFIG).unwrap();
365
366        let plan = with_official_modules(coil_runtime::RuntimeBuilder::new(
367            config,
368            DefaultAuthModelPackage::default(),
369        ))
370        .build()
371        .unwrap();
372        let names = plan
373            .modules
374            .iter()
375            .map(|manifest| manifest.name.as_str())
376            .collect::<Vec<_>>();
377
378        assert_eq!(names, OFFICIAL_MODULE_NAMES);
379    }
380
381    #[test]
382    fn module_helpers_expose_stable_customer_facing_factories() {
383        assert_eq!(modules::admin().manifest().name, "admin");
384        assert_eq!(modules::cms().manifest().name, "cms");
385        assert_eq!(modules::commerce().manifest().name, "commerce");
386        assert_eq!(
387            modules::commerce_payments_stripe().manifest().name,
388            "commerce-payments-stripe"
389        );
390        assert_eq!(modules::events().manifest().name, "events");
391        assert_eq!(modules::media().manifest().name, "media");
392        assert_eq!(modules::memberships().manifest().name, "memberships");
393        assert_eq!(modules::ops().manifest().name, "ops");
394    }
395
396    #[test]
397    fn official_module_reports_unknown_module_names() {
398        let error = match official_module("not-real") {
399            Ok(_) => panic!("expected unsupported module error"),
400            Err(error) => error,
401        };
402
403        assert_eq!(error.to_string(), "unsupported official module `not-real`");
404    }
405}