zino_core/application/
mod.rs

1//! High level abstractions for the application.
2//!
3//! # Examples
4//!
5//! ```rust,ignore
6//! use casbin::prelude::*;
7//! use std::sync::OnceLock;
8//! use zino_core::application::{Application, Plugin};
9//!
10//! #[derive(Debug, Clone, Copy)]
11//! pub struct Casbin;
12//!
13//! impl Casbin {
14//!     pub fn init() -> Plugin {
15//!         let loader = Box::pin(async {
16//!             let model_file = "./config/casbin/model.conf";
17//!             let policy_file = "./config/casbin/policy.csv";
18//!             let enforcer = Enforcer::new(model_file, policy_file).await?;
19//!             if CASBIN_ENFORCER.set(enforcer).is_err() {
20//!                 tracing::error!("fail to initialize the Casbin enforcer");
21//!             }
22//!             Ok(())
23//!         });
24//!         Plugin::with_loader("casbin", loader)
25//!     }
26//! }
27//!
28//! static CASBIN_ENFORCER: OnceLock<Enforcer> = OnceLock::new();
29//!
30//! fn main() {
31//!     zino::Cluster::boot()
32//!         .add_plugin(Casbin::init())
33//!         .run()
34//! }
35//! ```
36
37use crate::{
38    LazyLock, Map,
39    datetime::DateTime,
40    extension::{JsonObjectExt, TomlTableExt},
41    schedule::{AsyncJobScheduler, AsyncScheduler, Scheduler},
42    state::{Env, State},
43};
44use ahash::{HashMap, HashMapExt};
45use std::{
46    borrow::Cow,
47    env, fs,
48    path::{Component, Path, PathBuf},
49    thread,
50};
51use toml::value::Table;
52
53mod agent;
54mod app_type;
55mod plugin;
56mod secret_key;
57mod server_tag;
58mod static_record;
59
60#[cfg(feature = "http-client")]
61pub(crate) mod http_client;
62
63#[cfg(feature = "metrics")]
64mod metrics_exporter;
65
66#[cfg(feature = "preferences")]
67mod preferences;
68
69#[cfg(feature = "sentry")]
70mod sentry_client;
71
72#[cfg(feature = "tracing-subscriber")]
73mod tracing_subscriber;
74
75pub(crate) use secret_key::SECRET_KEY;
76
77#[cfg(feature = "preferences")]
78pub use preferences::Preferences;
79
80#[cfg(feature = "http-client")]
81use crate::{error::Error, extension::HeaderMapExt, trace::TraceContext};
82
83pub use agent::Agent;
84pub use app_type::AppType;
85pub use plugin::Plugin;
86pub use server_tag::ServerTag;
87pub use static_record::StaticRecord;
88
89/// Application interfaces.
90pub trait Application {
91    /// Routes.
92    type Routes;
93
94    /// Application type.
95    const APP_TYPE: AppType;
96
97    /// Registers default routes.
98    fn register(self, routes: Self::Routes) -> Self;
99
100    /// Runs the application with an optional scheduler for async jobs.
101    fn run_with<T: AsyncScheduler + Send + 'static>(self, scheduler: T);
102
103    /// Boots the application with the default initialization.
104    fn boot() -> Self
105    where
106        Self: Default,
107    {
108        // Loads the `.env` file from the current directory or parents
109        #[cfg(feature = "dotenv")]
110        dotenvy::dotenv().ok();
111
112        // Tracing subscriber
113        #[cfg(feature = "tracing-subscriber")]
114        tracing_subscriber::init::<Self>();
115
116        // Secret keys
117        secret_key::init::<Self>();
118
119        // Metrics exporter
120        #[cfg(feature = "metrics")]
121        metrics_exporter::init::<Self>();
122
123        // HTTP client
124        #[cfg(feature = "http-client")]
125        http_client::init::<Self>();
126
127        // Initializes the directories to ensure that they are ready for use
128        for path in SHARED_DIRS.values() {
129            if !path.exists() {
130                if let Err(err) = fs::create_dir_all(path) {
131                    let path = path.display();
132                    tracing::error!("fail to create the directory {path}: {err}");
133                }
134            }
135        }
136
137        Self::default()
138    }
139
140    /// Boots the application with a custom initialization.
141    fn boot_with<F>(init: F) -> Self
142    where
143        Self: Default,
144        F: FnOnce(&'static State<Map>),
145    {
146        let app = Self::boot();
147        init(Self::shared_state());
148        app
149    }
150
151    /// Registers routes with a server tag.
152    #[inline]
153    fn register_with(self, server_tag: ServerTag, routes: Self::Routes) -> Self
154    where
155        Self: Sized,
156    {
157        if server_tag == ServerTag::Debug {
158            self.register(routes)
159        } else {
160            self
161        }
162    }
163
164    /// Registers routes for debugger.
165    #[inline]
166    fn register_debug(self, routes: Self::Routes) -> Self
167    where
168        Self: Sized,
169    {
170        self.register_with(ServerTag::Debug, routes)
171    }
172
173    /// Adds a custom plugin.
174    #[inline]
175    fn add_plugin(self, plugin: Plugin) -> Self
176    where
177        Self: Sized,
178    {
179        tracing::info!(plugin_name = plugin.name());
180        self
181    }
182
183    /// Returns a reference to the shared application state.
184    #[inline]
185    fn shared_state() -> &'static State<Map> {
186        &SHARED_APP_STATE
187    }
188
189    /// Returns the application env.
190    #[inline]
191    fn env() -> &'static Env {
192        SHARED_APP_STATE.env()
193    }
194
195    /// Returns a reference to the shared application config.
196    #[inline]
197    fn config() -> &'static Table {
198        SHARED_APP_STATE.config()
199    }
200
201    /// Returns a reference to the shared application state data.
202    #[inline]
203    fn state_data() -> &'static Map {
204        SHARED_APP_STATE.data()
205    }
206
207    /// Returns the application name.
208    #[inline]
209    fn name() -> &'static str {
210        APP_NAME.as_ref()
211    }
212
213    /// Returns the application version.
214    #[inline]
215    fn version() -> &'static str {
216        APP_VERSION.as_ref()
217    }
218
219    /// Returns the domain for the application.
220    #[inline]
221    fn domain() -> &'static str {
222        APP_DOMAIN.as_ref()
223    }
224
225    /// Returns the secret key for the application.
226    /// It should have at least 64 bytes.
227    ///
228    /// # Note
229    ///
230    /// This should only be used for internal services. Do not expose it to external users.
231    #[inline]
232    fn secret_key() -> &'static [u8] {
233        SECRET_KEY.get().expect("fail to get the secret key")
234    }
235
236    /// Returns the project directory for the application.
237    #[inline]
238    fn project_dir() -> &'static PathBuf {
239        &PROJECT_DIR
240    }
241
242    /// Returns the config directory for the application.
243    ///
244    /// # Note
245    ///
246    /// The default config directory is `${PROJECT_DIR}/config`.
247    /// It can also be specified by the environment variable `ZINO_APP_CONFIG_DIR`.
248    #[inline]
249    fn config_dir() -> &'static PathBuf {
250        &CONFIG_DIR
251    }
252
253    /// Returns the shared directory with a specific name,
254    /// which is defined in the `dirs` table.
255    ///
256    /// # Examples
257    ///
258    /// ```toml
259    /// [dirs]
260    /// data = "/data/zino" # an absolute path
261    /// cache = "~/zino/cache" # a path in the home dir
262    /// assets = "local/assets" # a path in the project dir
263    /// ```
264    #[inline]
265    fn shared_dir(name: &str) -> Cow<'_, PathBuf> {
266        SHARED_DIRS
267            .get(name)
268            .map(Cow::Borrowed)
269            .unwrap_or_else(|| Cow::Owned(Self::parse_path(name)))
270    }
271
272    /// Parses an absolute path, or a path relative to the home dir `~/` or project dir.
273    #[inline]
274    fn parse_path(path: &str) -> PathBuf {
275        join_path(&PROJECT_DIR, path)
276    }
277
278    /// Spawns a new thread to run cron jobs.
279    fn spawn<T>(self, mut scheduler: T) -> Self
280    where
281        Self: Sized,
282        T: Scheduler + Send + 'static,
283    {
284        thread::spawn(move || {
285            loop {
286                scheduler.tick();
287                if let Some(duration) = scheduler.time_till_next_job() {
288                    thread::sleep(duration);
289                }
290            }
291        });
292        self
293    }
294
295    /// Runs the application with a default job scheduler.
296    #[inline]
297    fn run(self)
298    where
299        Self: Sized,
300    {
301        self.run_with(AsyncJobScheduler::default());
302    }
303
304    /// Loads resources after booting the application.
305    #[inline]
306    async fn load() {}
307
308    /// Handles the graceful shutdown.
309    #[inline]
310    async fn shutdown() {}
311
312    /// Makes an HTTP request to the provided URL.
313    #[cfg(feature = "http-client")]
314    async fn fetch(url: &str, options: Option<&Map>) -> Result<reqwest::Response, Error> {
315        let mut trace_context = TraceContext::new();
316        trace_context.record_trace_state();
317        http_client::request_builder(url, options)?
318            .header("traceparent", trace_context.traceparent())
319            .header("tracestate", trace_context.tracestate())
320            .send()
321            .await
322            .map_err(Error::from)
323    }
324
325    /// Makes an HTTP request to the provided URL and
326    /// deserializes the response body via JSON.
327    #[cfg(feature = "http-client")]
328    async fn fetch_json<T: serde::de::DeserializeOwned>(
329        url: &str,
330        options: Option<&Map>,
331    ) -> Result<T, Error> {
332        let response = Self::fetch(url, options).await?.error_for_status()?;
333        let data = if response.headers().has_json_content_type() {
334            response.json().await?
335        } else {
336            let text = response.text().await?;
337            serde_json::from_str(&text)?
338        };
339        Ok(data)
340    }
341}
342
343/// Joins a path to the specific dir.
344fn join_path(dir: &Path, path: &str) -> PathBuf {
345    fn join_path_components(mut full_path: PathBuf, path: &str) -> PathBuf {
346        for component in Path::new(path).components() {
347            match component {
348                Component::CurDir => (),
349                Component::ParentDir => {
350                    full_path.pop();
351                }
352                _ => {
353                    full_path.push(component);
354                }
355            }
356        }
357        full_path
358    }
359
360    if path.starts_with('/') {
361        path.into()
362    } else if let Some(path) = path.strip_prefix("~/") {
363        if let Some(home_dir) = dirs::home_dir() {
364            join_path_components(home_dir, path)
365        } else {
366            join_path_components(dir.to_path_buf(), path)
367        }
368    } else {
369        join_path_components(dir.to_path_buf(), path)
370    }
371}
372
373/// App name.
374static APP_NAME: LazyLock<&'static str> = LazyLock::new(|| {
375    SHARED_APP_STATE
376        .config()
377        .get_str("name")
378        .unwrap_or_else(|| {
379            env::var("CARGO_PKG_NAME")
380                .expect("fail to get the environment variable `CARGO_PKG_NAME`")
381                .leak()
382        })
383});
384
385/// App version.
386static APP_VERSION: LazyLock<&'static str> = LazyLock::new(|| {
387    SHARED_APP_STATE
388        .config()
389        .get_str("version")
390        .unwrap_or_else(|| {
391            env::var("CARGO_PKG_VERSION")
392                .expect("fail to get the environment variable `CARGO_PKG_VERSION`")
393                .leak()
394        })
395});
396
397/// App domain.
398static APP_DOMAIN: LazyLock<&'static str> = LazyLock::new(|| {
399    SHARED_APP_STATE
400        .config()
401        .get_str("domain")
402        .unwrap_or("localhost")
403});
404
405/// The project directory.
406static PROJECT_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
407    env::var("CARGO_MANIFEST_DIR")
408        .ok()
409        .filter(|var| !var.is_empty())
410        .map(PathBuf::from)
411        .unwrap_or_else(|| {
412            if cfg!(not(debug_assertions)) && cfg!(target_os = "macos") {
413                if let Ok(mut path) = env::current_exe() {
414                    path.pop();
415                    if path.ends_with("Contents/MacOS") {
416                        path.pop();
417                        path.push("Resources");
418                        if path.exists() && path.is_dir() {
419                            return path;
420                        }
421                    }
422                }
423            }
424            tracing::warn!(
425                "fail to get the environment variable `CARGO_MANIFEST_DIR`; \
426                    current directory will be used as the project directory"
427            );
428            env::current_dir()
429                .expect("project directory does not exist or permissions are insufficient")
430        })
431});
432
433/// The config directory.
434static CONFIG_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
435    env::var("ZINO_APP_CONFIG_DIR")
436        .ok()
437        .filter(|var| !var.is_empty())
438        .map(PathBuf::from)
439        .unwrap_or_else(|| PROJECT_DIR.join("config"))
440});
441
442/// Shared directories.
443static SHARED_DIRS: LazyLock<HashMap<String, PathBuf>> = LazyLock::new(|| {
444    let mut dirs = HashMap::new();
445    if let Some(config) = SHARED_APP_STATE.get_config("dirs") {
446        for (key, value) in config {
447            if let Some(path) = value.as_str() {
448                dirs.insert(key.to_owned(), join_path(&PROJECT_DIR, path));
449            }
450        }
451    }
452    dirs
453});
454
455/// Shared app state.
456static SHARED_APP_STATE: LazyLock<State<Map>> = LazyLock::new(|| {
457    let mut state = State::default();
458    state.load_config();
459
460    let config = state.config();
461    let app_name = config
462        .get_str("name")
463        .map(|s| s.to_owned())
464        .unwrap_or_else(|| {
465            env::var("CARGO_PKG_NAME")
466                .expect("fail to get the environment variable `CARGO_PKG_NAME`")
467        });
468    let app_version = config
469        .get_str("version")
470        .map(|s| s.to_owned())
471        .unwrap_or_else(|| {
472            env::var("CARGO_PKG_VERSION")
473                .expect("fail to get the environment variable `CARGO_PKG_VERSION`")
474        });
475
476    let mut data = Map::new();
477    data.upsert("app.name", app_name);
478    data.upsert("app.version", app_version);
479    data.upsert("app.booted_at", DateTime::now());
480    state.set_data(data);
481    state
482});