diff --git a/Cargo.lock b/Cargo.lock index 215362f..f2e0411 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,7 +163,7 @@ dependencies = [ "proc-macro2", "quote", "strum 0.27.2", - "syn", + "syn 2.0.117", "thiserror", ] @@ -228,7 +228,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -239,7 +239,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -503,7 +503,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -665,7 +665,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -678,7 +678,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -689,7 +689,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -700,7 +700,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -738,7 +738,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -748,7 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -771,7 +771,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -957,7 +957,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1120,7 +1120,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hello-nestforge" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1131,7 +1131,7 @@ dependencies = [ [[package]] name = "hello-nestforge-graphql" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "async-graphql", @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "hello-nestforge-grpc" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1157,7 +1157,7 @@ dependencies = [ [[package]] name = "hello-nestforge-microservices" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1169,7 +1169,7 @@ dependencies = [ [[package]] name = "hello-nestforge-websockets" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1411,6 +1411,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "1.1.0" @@ -1432,6 +1442,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + [[package]] name = "indexmap" version = "1.9.3" @@ -1486,7 +1502,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1680,7 +1696,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1729,7 +1745,7 @@ dependencies = [ [[package]] name = "nestforge" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "async-graphql", @@ -1759,7 +1775,7 @@ dependencies = [ [[package]] name = "nestforge-cache" -version = "1.5.0" +version = "1.6.0" dependencies = [ "axum 0.8.8", "http", @@ -1771,7 +1787,7 @@ dependencies = [ [[package]] name = "nestforge-cli" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "clap", @@ -1788,34 +1804,37 @@ dependencies = [ [[package]] name = "nestforge-config" -version = "1.5.0" +version = "1.6.0" dependencies = [ + "dotenvy", "thiserror", ] [[package]] name = "nestforge-core" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "axum 0.8.8", "http", + "nestforge-config", "serde", "serde_json", "thiserror", "tokio", + "validator", ] [[package]] name = "nestforge-data" -version = "1.5.0" +version = "1.6.0" dependencies = [ "thiserror", ] [[package]] name = "nestforge-db" -version = "1.5.0" +version = "1.6.0" dependencies = [ "sqlx", "thiserror", @@ -1824,7 +1843,7 @@ dependencies = [ [[package]] name = "nestforge-graphql" -version = "1.5.0" +version = "1.6.0" dependencies = [ "async-graphql", "async-graphql-axum", @@ -1834,7 +1853,7 @@ dependencies = [ [[package]] name = "nestforge-grpc" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "nestforge-core", @@ -1848,7 +1867,7 @@ dependencies = [ [[package]] name = "nestforge-http" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -1859,16 +1878,16 @@ dependencies = [ [[package]] name = "nestforge-macros" -version = "1.5.0" +version = "1.6.0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "nestforge-microservices" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "nestforge-core", @@ -1878,14 +1897,14 @@ dependencies = [ [[package]] name = "nestforge-mongo" -version = "1.5.0" +version = "1.6.0" dependencies = [ "nestforge-data", ] [[package]] name = "nestforge-openapi" -version = "1.5.0" +version = "1.6.0" dependencies = [ "axum 0.8.8", "nestforge-core", @@ -1897,7 +1916,7 @@ dependencies = [ [[package]] name = "nestforge-orm" -version = "1.5.0" +version = "1.6.0" dependencies = [ "nestforge-db", "thiserror", @@ -1906,14 +1925,14 @@ dependencies = [ [[package]] name = "nestforge-redis" -version = "1.5.0" +version = "1.6.0" dependencies = [ "nestforge-data", ] [[package]] name = "nestforge-schedule" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "nestforge-core", @@ -1922,7 +1941,7 @@ dependencies = [ [[package]] name = "nestforge-testing" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "async-graphql", @@ -1939,7 +1958,7 @@ dependencies = [ [[package]] name = "nestforge-websockets" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "axum 0.8.8", @@ -2125,7 +2144,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2155,7 +2174,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2242,7 +2261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2254,6 +2273,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2283,7 +2326,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2529,7 +2572,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2742,7 +2785,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -2764,7 +2807,7 @@ dependencies = [ "sqlx-core", "sqlx-mysql", "sqlx-postgres", - "syn", + "syn 2.0.117", "tokio", "url", ] @@ -2934,7 +2977,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -2946,7 +2989,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2976,6 +3019,17 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3001,7 +3055,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3054,7 +3108,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3105,7 +3159,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3273,7 +3327,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3395,7 +3449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna", + "idna 1.1.0", "percent-encoding", "serde", ] @@ -3418,6 +3472,48 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "validator" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" +dependencies = [ + "idna 0.4.0", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -3501,7 +3597,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3866,7 +3962,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3882,7 +3978,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3949,7 +4045,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3970,7 +4066,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3990,7 +4086,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4030,7 +4126,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cc025b3..b00bfea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ serde_json = "1" http = "1" anyhow = "1" thiserror = "2" +validator = { version = "0.16", features = ["derive"] } sha2 = "0.10" tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors"] } diff --git a/crates/nestforge-cli/src/main.rs b/crates/nestforge-cli/src/main.rs index 660593d..21f30e8 100644 --- a/crates/nestforge-cli/src/main.rs +++ b/crates/nestforge-cli/src/main.rs @@ -2724,35 +2724,16 @@ impl HealthController { .to_string() } -fn template_app_config_rs(transport: AppTransport) -> String { - let default_app_name = match transport { - AppTransport::Http => "NestForge HTTP", - AppTransport::Graphql => "NestForge GraphQL", - AppTransport::Grpc => "NestForge gRPC", - AppTransport::Websockets => "NestForge WebSockets", - AppTransport::Microservices => "NestForge Microservices", - }; - - format!( - r#"use nestforge::{{prelude::*, ConfigModule, ConfigOptions}}; - -#[injectable(factory = load_app_config)] -pub struct AppConfig {{ - pub app_name: String, -}} +fn template_app_config_rs(_transport: AppTransport) -> String { + r#"use nestforge_config::{ConfigService, ConfigModule}; -fn load_app_config() -> anyhow::Result {{ - Ok(ConfigModule::for_root::(ConfigOptions::new().env_file(".env"))?) -}} +pub type AppConfig = ConfigService; -impl nestforge::FromEnv for AppConfig {{ - fn from_env(env: &nestforge::EnvStore) -> Result {{ - let app_name = env.get("APP_NAME").unwrap_or("{default_app_name}").to_string(); - Ok(Self {{ app_name }}) - }} -}} +pub fn load_config() -> AppConfig { + ConfigModule::for_root_with_options(ConfigModule::for_root().env_file(".env")) +} "# - ) + .to_string() } fn template_graphql_schema_rs() -> String { diff --git a/crates/nestforge-config/Cargo.toml b/crates/nestforge-config/Cargo.toml index c13fdb0..0780335 100644 --- a/crates/nestforge-config/Cargo.toml +++ b/crates/nestforge-config/Cargo.toml @@ -12,3 +12,4 @@ categories = ["config"] [dependencies] thiserror = { workspace = true } +dotenvy = "0.15" diff --git a/crates/nestforge-config/README.md b/crates/nestforge-config/README.md index 475919d..88fc6e3 100644 --- a/crates/nestforge-config/README.md +++ b/crates/nestforge-config/README.md @@ -1,6 +1,60 @@ # nestforge-config -Environment configuration loading and validation utilities for NestForge. +A NestJS-inspired configuration module for NestForge applications. -- Repository: https://github.com/vernonthedev/nestforge -- Docs: https://github.com/vernonthedev/nestforge/wiki +## Features + +- **Type-safe configuration** - Get typed values from environment variables +- **NestJS-like API** - Familiar patterns for NestJS developers +- **DI Integration** - Seamlessly works with NestForge's dependency injection +- **Simple getters** - No unwrap_or chains, just `get_string_or("KEY", "default")` +- **Dotenv support** - Automatic loading from `.env` files + +## Quick Start + +```rust +use nestforge_config::{ConfigService, ConfigModule}; + +pub type AppConfig = ConfigService; + +pub fn load_config() -> AppConfig { + ConfigModule::for_root_with_options(ConfigModule::for_root().env_file(".env")) +} +``` + +## Usage in Services + +```rust +use nestforge::prelude::*; + +pub struct MyService { + config: Config, +} + +impl MyService { + pub fn do_something(&self) { + let app_name = self.config.get_string_or("APP_NAME", "My App"); + let port = self.config.get_u16_or("PORT", 3000); + let debug = self.config.get_bool_or("DEBUG", false); + } +} +``` + +## Available Methods + +| Method | Description | +|--------|-------------| +| `get_string("KEY")` | Get string (default: `""`) | +| `get_string_or("KEY", "default")` | Get with default | +| `get_u16("KEY")` | Get u16 (default: `0`) | +| `get_u16_or("KEY", 3000)` | Get with default | +| `get_bool("KEY")` | Get bool (default: `false`) | +| `get_bool_or("KEY", true)` | Get with default | +| `get("KEY")` | Get `Option<&str>` | +| `has("KEY")` | Check if key exists | + +## Resources + +- [Documentation](https://github.com/vernonthedev/nestforge/wiki) +- [Examples](https://github.com/vernonthedev/nestforge/tree/main/examples) +- [Discord Community](https://discord.gg/nestforge) diff --git a/crates/nestforge-config/src/lib.rs b/crates/nestforge-config/src/lib.rs index af0580d..645b05a 100644 --- a/crates/nestforge-config/src/lib.rs +++ b/crates/nestforge-config/src/lib.rs @@ -1,52 +1,230 @@ -use std::{collections::HashMap, env, fs, path::Path}; - +use std::collections::HashMap; +use std::env; +use std::path::Path; use thiserror::Error; -/** - * ConfigError - * - * Error types that can occur during configuration loading and validation. - */ #[derive(Debug, Error)] pub enum ConfigError { #[error("Failed to read env file `{path}`: {source}")] ReadEnvFile { path: String, #[source] - source: std::io::Error, + source: dotenvy::Error, }, - #[error("Missing required config key: {key}")] + #[error("Missing config key: {key}")] MissingKey { key: String }, - #[error("Environment validation failed")] - Validation { issues: Vec }, } -/** - * EnvValidationIssue - * - * Represents a single validation issue found in the environment configuration. - */ +#[derive(Clone, Debug, Default)] +pub struct EnvSchema { + requirements: Vec, +} + +impl EnvSchema { + pub fn new() -> Self { + Self::default() + } + + pub fn required(&mut self, key: &str) -> &mut Self { + self.requirements.push(key.to_string()); + self + } +} + +#[derive(Clone, Debug)] +pub struct EnvStore { + values: HashMap, +} + +impl EnvStore { + pub fn new() -> Self { + Self::default() + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.values.get(key).map(String::as_str) + } +} + +impl Default for EnvStore { + fn default() -> Self { + Self { + values: env::vars().collect(), + } + } +} + +impl From for EnvStore { + fn from(config: ConfigService) -> Self { + Self { + values: config.values, + } + } +} + #[derive(Clone, Debug)] pub struct EnvValidationIssue { pub key: String, pub message: String, } -/** - * ConfigOptions - * - * Configuration options for loading environment variables. - * - * # Fields - * - `env_file_path`: Path to the .env file (default: ".env") - * - `include_process_env`: Whether to include process environment variables - * - `schema`: Optional validation schema - */ +pub trait FromEnv: Sized { + fn from_env(env: &EnvStore) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct ConfigService { + values: HashMap, +} + +impl ConfigService { + pub fn new() -> Self { + Self::default() + } + + pub fn load() -> Result { + Self::load_with_options(&ConfigOptions::default()) + } + + pub fn load_with_options(options: &ConfigOptions) -> Result { + let path_ref = Path::new(&options.env_file_path); + let mut values = if options.include_process_env { + env::vars().collect::>() + } else { + HashMap::new() + }; + + if path_ref.exists() { + dotenvy::from_path_iter(path_ref) + .map_err(|source| ConfigError::ReadEnvFile { + path: path_ref.display().to_string(), + source, + })? + .for_each(|result| { + if let Ok((key, value)) = result { + values.insert(key, value); + } + }); + } + + Ok(Self { values }) + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.values.get(key).map(String::as_str) + } + + pub fn get_string(&self, key: &str) -> String { + self.get(key).map(|v| v.to_string()).unwrap_or_default() + } + + pub fn get_string_or(&self, key: &str, default: &str) -> String { + self.get(key) + .map(|v| v.to_string()) + .unwrap_or_else(|| default.to_string()) + } + + pub fn get_i32(&self, key: &str) -> i32 { + self.get(key).and_then(|v| v.parse().ok()).unwrap_or(0) + } + + pub fn get_i32_or(&self, key: &str, default: i32) -> i32 { + self.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + pub fn get_u16(&self, key: &str) -> u16 { + self.get(key).and_then(|v| v.parse().ok()).unwrap_or(0) + } + + pub fn get_u16_or(&self, key: &str, default: u16) -> u16 { + self.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + pub fn get_u32(&self, key: &str) -> u32 { + self.get(key).and_then(|v| v.parse().ok()).unwrap_or(0) + } + + pub fn get_u32_or(&self, key: &str, default: u32) -> u32 { + self.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + pub fn get_bool(&self, key: &str) -> bool { + self.get(key) + .map(|v| v == "true" || v == "1" || v == "yes") + .unwrap_or(false) + } + + pub fn get_bool_or(&self, key: &str, default: bool) -> bool { + self.get(key) + .map(|v| v == "true" || v == "1" || v == "yes") + .unwrap_or(default) + } + + pub fn get_usize(&self, key: &str) -> usize { + self.get(key).and_then(|v| v.parse().ok()).unwrap_or(0) + } + + pub fn get_usize_or(&self, key: &str, default: usize) -> usize { + self.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + pub fn get_f64(&self, key: &str) -> f64 { + self.get(key).and_then(|v| v.parse().ok()).unwrap_or(0.0) + } + + pub fn get_f64_or(&self, key: &str, default: f64) -> f64 { + self.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + pub fn get_isize(&self, key: &str) -> isize { + self.get(key).and_then(|v| v.parse().ok()).unwrap_or(0) + } + + pub fn get_isize_or(&self, key: &str, default: isize) -> isize { + self.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + pub fn get_i64(&self, key: &str) -> i64 { + self.get(key).and_then(|v| v.parse().ok()).unwrap_or(0) + } + + pub fn get_i64_or(&self, key: &str, default: i64) -> i64 { + self.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + pub fn get_u64(&self, key: &str) -> u64 { + self.get(key).and_then(|v| v.parse().ok()).unwrap_or(0) + } + + pub fn get_u64_or(&self, key: &str, default: u64) -> u64 { + self.get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + + pub fn has(&self, key: &str) -> bool { + self.values.contains_key(key) + } +} + #[derive(Clone, Debug)] pub struct ConfigOptions { pub env_file_path: String, pub include_process_env: bool, - pub schema: Option, } impl Default for ConfigOptions { @@ -54,328 +232,141 @@ impl Default for ConfigOptions { Self { env_file_path: ".env".to_string(), include_process_env: true, - schema: None, } } } impl ConfigOptions { - /** - * Creates a new ConfigOptions with default values. - */ pub fn new() -> Self { Self::default() } - /** - * Sets the path to the environment file. - */ pub fn env_file(mut self, path: impl Into) -> Self { self.env_file_path = path.into(); self } - /** - * Excludes process environment variables, using only the .env file. - */ pub fn without_process_env(mut self) -> Self { self.include_process_env = false; self } - - /** - * Adds a validation schema to enforce environment variable rules. - */ - pub fn validate_with(mut self, schema: EnvSchema) -> Self { - self.schema = Some(schema); - self - } -} - -/** - * EnvSchema - * - * A validation schema for environment variables. Defines rules that - * the loaded environment must satisfy. - * - * # Example - * ```rust - * let schema = EnvSchema::new() - * .required("DATABASE_URL") - * .min_len("API_KEY", 32) - * .one_of("ENV", &["development", "staging", "production"]); - * ``` - */ -#[derive(Clone, Debug, Default)] -pub struct EnvSchema { - rules: HashMap>, } -/** - * EnvRule - * - * Internal enum representing validation rules for environment variables. - */ -#[derive(Clone, Debug)] -enum EnvRule { - Required, - MinLen(usize), - OneOf(Vec), -} +pub struct ConfigModule; -impl EnvSchema { - /** - * Creates a new empty validation schema. - */ - pub fn new() -> Self { - Self::default() +impl ConfigModule { + pub fn for_root() -> ConfigOptions { + ConfigOptions::new() } - /** - * Adds a required field rule - the environment variable must be present and non-empty. - */ - pub fn required(mut self, key: impl Into) -> Self { - self.rules - .entry(key.into()) - .or_default() - .push(EnvRule::Required); - self + pub fn for_root_with_options(options: ConfigOptions) -> ConfigService { + ConfigService::load_with_options(&options).expect("Failed to load configuration") } - /** - * Adds a minimum length rule for a string value. - */ - pub fn min_len(mut self, key: impl Into, min: usize) -> Self { - self.rules - .entry(key.into()) - .or_default() - .push(EnvRule::MinLen(min)); - self + pub fn for_feature() -> ConfigOptions { + ConfigOptions::new() } +} - /** - * Adds a value must be one of the allowed values rule. - */ - pub fn one_of(mut self, key: impl Into, values: &[&str]) -> Self { - self.rules - .entry(key.into()) - .or_default() - .push(EnvRule::OneOf( - values.iter().map(|v| (*v).to_string()).collect(), - )); - self - } +pub fn load_config() -> ConfigService { + ConfigModule::for_root_with_options(ConfigModule::for_root()) +} - fn validate(&self, env: &EnvStore) -> Result<(), ConfigError> { - let mut issues = Vec::new(); - - for (key, rules) in &self.rules { - let value = env.get(key); - for rule in rules { - match rule { - EnvRule::Required => { - if value.map(|v| v.trim().is_empty()).unwrap_or(true) { - issues.push(EnvValidationIssue { - key: key.clone(), - message: format!("{} is required", key), - }); - } - } - EnvRule::MinLen(min) => { - if let Some(v) = value { - if v.len() < *min { - issues.push(EnvValidationIssue { - key: key.clone(), - message: format!("{} must be at least {} chars", key, min), - }); - } - } - } - EnvRule::OneOf(allowed) => { - if let Some(v) = value { - if !allowed.iter().any(|entry| entry == v) { - issues.push(EnvValidationIssue { - key: key.clone(), - message: format!( - "{} must be one of [{}]", - key, - allowed.join(", ") - ), - }); - } - } - } - } - } - } +pub struct Config { + _phantom: std::marker::PhantomData, +} - if issues.is_empty() { - Ok(()) - } else { - Err(ConfigError::Validation { issues }) +impl Config { + pub fn new() -> Self { + Self { + _phantom: std::marker::PhantomData, } } } -/** - * EnvStore - * - * A store for environment variable key-value pairs. - * - * # Loading - * - Loads from .env file if present - * - Includes process environment variables by default - * - Supports custom loading options - */ -#[derive(Clone, Debug, Default)] -pub struct EnvStore { - values: HashMap, +impl Default for Config { + fn default() -> Self { + Self::new() + } } -impl EnvStore { - /** - * Loads environment variables using default options. - */ - pub fn load() -> Result { - Self::load_with_options(&ConfigOptions::default()) +pub fn register_config( + name: &'static str, + factory: fn() -> T, +) -> ConfigRegistration { + ConfigRegistration { + name, + _phantom: std::marker::PhantomData, + factory, } +} - /** - * Loads environment variables from a specific file. - */ - pub fn load_from_file(path: impl AsRef) -> Result { - Self::load_with_options(&ConfigOptions::new().env_file(path.as_ref().display().to_string())) +pub struct ConfigRegistration { + #[allow(dead_code)] + name: &'static str, + _phantom: std::marker::PhantomData, + factory: fn() -> T, +} + +impl ConfigRegistration { + pub fn load(&self) -> T { + (self.factory)() } +} - /** - * Loads environment variables with custom options. - */ - pub fn load_with_options(options: &ConfigOptions) -> Result { - let path_ref = Path::new(&options.env_file_path); - let mut values = if options.include_process_env { - env::vars().collect::>() - } else { - HashMap::new() - }; +#[cfg(test)] +mod tests { + use super::*; - if path_ref.exists() { - let content = - fs::read_to_string(path_ref).map_err(|source| ConfigError::ReadEnvFile { - path: path_ref.display().to_string(), - source, - })?; - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - if let Some((key, value)) = trimmed.split_once('=') { - values.entry(key.trim().to_string()).or_insert_with(|| { - value - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string() - }); - } - } - } + #[test] + fn test_config_service_load() { + std::env::set_var("APP_NAME", "TestApp"); + std::env::set_var("APP_PORT", "8080"); - Ok(Self { values }) - } + let config = ConfigService::load().unwrap(); - /** - * Creates an EnvStore from an iterator of key-value pairs. - */ - pub fn from_pairs(pairs: impl IntoIterator) -> Self { - Self { - values: pairs.into_iter().collect(), - } - } + assert_eq!(config.get("APP_NAME"), Some("TestApp")); + assert_eq!(config.get_string("APP_NAME"), "TestApp"); + assert_eq!(config.get_u16("APP_PORT"), 8080); + assert_eq!(config.get_u16_or("MISSING", 3000), 3000); + assert!(config.has("APP_NAME")); + assert!(!config.has("MISSING")); - /** - * Gets a value by key, returning None if not found. - */ - pub fn get(&self, key: &str) -> Option<&str> { - self.values.get(key).map(String::as_str) + std::env::remove_var("APP_NAME"); + std::env::remove_var("APP_PORT"); } - /** - * Gets a value by key, erroring if not found. - */ - pub fn require(&self, key: &str) -> Result<&str, ConfigError> { - self.get(key).ok_or_else(|| ConfigError::MissingKey { - key: key.to_string(), - }) - } -} + #[test] + fn test_config_service_defaults() { + let config = ConfigService::new(); -/** - * FromEnv Trait - * - * A trait for types that can be constructed from environment variables. - * - * # Implementation - * ```rust - * struct AppConfig { - * database_url: String, - * port: u16, - * } - * - * impl FromEnv for AppConfig { - * fn from_env(env: &EnvStore) -> Result { - * Ok(Self { - * database_url: env.require("DATABASE_URL")?.to_string(), - * port: env.get("PORT").unwrap_or("8080").parse().unwrap(), - * }) - * } - * } - * ``` - */ -pub trait FromEnv: Sized { - fn from_env(env: &EnvStore) -> Result; -} + assert_eq!(config.get_string("MISSING"), ""); + assert_eq!(config.get_string_or("MISSING", "default"), "default"); + assert_eq!(config.get_u16_or("MISSING", 3000), 3000); + assert_eq!(config.get_bool_or("MISSING", true), true); + } -/** - * Loads configuration using the default options. - * - * # Type Parameters - * - `T`: The configuration type implementing FromEnv - */ -pub fn load_config() -> Result { - let env = EnvStore::load()?; - T::from_env(&env) -} + #[test] + fn test_config_options_builder() { + let options = ConfigOptions::new().env_file(".env.test"); + assert_eq!(options.env_file_path, ".env.test"); + } -/** - * ConfigModule - * - * Provides root-level configuration loading for NestForge applications. - */ -pub struct ConfigModule; + #[test] + fn test_register_config() { + let db_config = register_config("database", || DbConfig { + host: "localhost".to_string(), + port: 5432, + }); -impl ConfigModule { - /** - * Loads configuration for the application root. - * - * # Type Parameters - * - `T`: The configuration type implementing FromEnv - * - * # Arguments - * - `options`: Configuration loading options - */ - pub fn for_root(options: ConfigOptions) -> Result { - let env = EnvStore::load_with_options(&options)?; - if let Some(schema) = &options.schema { - schema.validate(&env)?; - } - T::from_env(&env) + let config = db_config.load(); + assert_eq!(config.host, "localhost"); + assert_eq!(config.port, 5432); } - /** - * Loads the raw environment store with custom options. - */ - pub fn env(options: ConfigOptions) -> Result { - EnvStore::load_with_options(&options) + #[derive(Debug, Clone)] + struct DbConfig { + host: String, + port: u16, } } diff --git a/crates/nestforge-core/Cargo.toml b/crates/nestforge-core/Cargo.toml index 147f4cd..66c9f69 100644 --- a/crates/nestforge-core/Cargo.toml +++ b/crates/nestforge-core/Cargo.toml @@ -12,10 +12,12 @@ keywords = ["rust", "framework", "di", "backend", "nestforge"] categories = ["web-programming", "web-programming::http-server"] [dependencies] -anyhow = { workspace = true } # anyhow = easy error handling for now. -thiserror = { workspace = true } # thiserror = cleaner custom errors later. +anyhow = { workspace = true } +thiserror = { workspace = true } axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } http = { workspace = true } tokio = { workspace = true } +nestforge-config = { path = "../nestforge-config" } +validator = { workspace = true } diff --git a/crates/nestforge-core/src/config.rs b/crates/nestforge-core/src/config.rs new file mode 100644 index 0000000..caeda07 --- /dev/null +++ b/crates/nestforge-core/src/config.rs @@ -0,0 +1,30 @@ +use crate::Container; +use anyhow::Result; +use nestforge_config::{ConfigModule, ConfigOptions, ConfigService}; + +pub fn register_config(container: &Container, options: ConfigOptions) -> Result<()> { + let config = ConfigModule::for_root_with_options(options); + container.register(config).map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_register_config_loads() { + std::env::set_var("APP_NAME", "TestApp"); + + let container = Container::new(); + let options = ConfigOptions::new(); + + let result = register_config(&container, options); + assert!(result.is_ok()); + + let resolved = container.resolve::(); + assert!(resolved.is_ok()); + assert_eq!(resolved.unwrap().get("APP_NAME"), Some("TestApp")); + + std::env::remove_var("APP_NAME"); + } +} diff --git a/crates/nestforge-core/src/lib.rs b/crates/nestforge-core/src/lib.rs index afd1edf..8b3a49d 100644 --- a/crates/nestforge-core/src/lib.rs +++ b/crates/nestforge-core/src/lib.rs @@ -8,6 +8,7 @@ * - In memory store */ pub mod auth; +pub mod config; pub mod container; pub mod documentation; pub mod error; @@ -26,6 +27,7 @@ pub mod store; pub mod validation; pub use auth::{AuthIdentity, AuthUser, BearerToken, OptionalAuthUser}; +pub use config::register_config; pub use container::{Container, ContainerError}; pub use documentation::{ openapi_array_schema_for, openapi_nullable_schema_for, openapi_schema_components_for, @@ -60,6 +62,9 @@ pub use response::{ pub use route_builder::RouteBuilder; pub use store::{Identifiable, InMemoryStore}; pub use validation::{Validate, ValidationErrors, ValidationIssue}; +pub use nestforge_config::{ + Config, ConfigError, ConfigModule, ConfigOptions, ConfigService, +}; pub type ApiResult = Result, HttpException>; pub type List = Vec; diff --git a/crates/nestforge-macros/src/lib.rs b/crates/nestforge-macros/src/lib.rs index 32b5283..21c2365 100644 --- a/crates/nestforge-macros/src/lib.rs +++ b/crates/nestforge-macros/src/lib.rs @@ -65,7 +65,7 @@ pub fn injectable(attr: TokenStream, item: TokenStream) -> TokenStream { ensure_derive_trait(&mut input.attrs, "Clone"); let name = &input.ident; - + /* We decide how to register the provider based on whether a factory was provided. If a factory is present, we wrap it in a closure that converts the result into an `IntoInjectableResult`. @@ -113,7 +113,7 @@ pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { let mut input = parse_macro_input!(item as ItemImpl); let self_ty = input.self_ty.clone(); - + /* First, we pull out any metadata from the top of the impl block. This includes things like `#[tag(...)]`, `#[authenticated]`, or `#[roles(...)]` that apply to all routes. @@ -131,7 +131,7 @@ pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { let ImplItem::Fn(ref mut method) = impl_item else { continue; }; - + /* Extract all the "middleware-like" metadata for this specific method. This includes guards, interceptors, and exception filters. @@ -139,7 +139,7 @@ pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { let (guards, interceptors, exception_filters) = extract_pipeline_meta(method); let version = extract_version_meta(method); let mut doc_meta = extract_route_doc_meta(method); - + /* Merge the controller-level settings (like tags or auth) into the route. Route-level settings generally add to or override controller-level ones. @@ -152,7 +152,7 @@ pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { doc_meta.requires_auth = controller_meta.requires_auth || doc_meta.requires_auth || !doc_meta.required_roles.is_empty(); - + let guards = merge_type_lists(controller_meta.guards.clone(), guards); let interceptors = merge_type_lists(controller_meta.interceptors.clone(), interceptors); let exception_filters = @@ -165,7 +165,7 @@ pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { if let Some((http_method, path)) = extract_route_meta(method) { let method_name = &method.sig.ident; let path_lit = LitStr::new(&path, method.sig.ident.span()); - + /* We generate the code to initialize all the guards and interceptors. These are instantiated as Arcs and passed to the route builder. @@ -173,7 +173,7 @@ pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { let guard_inits = guards.iter().map(|ty| { quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc } }); - + /* Special handling for auth and role guards. If authentication is required, we add the standard RequireAuthenticationGuard. @@ -199,20 +199,20 @@ pub fn routes(_attr: TokenStream, item: TokenStream) -> TokenStream { as std::sync::Arc } }; - + let interceptor_inits = interceptors.iter().map(|ty| { quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc } }); let exception_filter_inits = exception_filters.iter().map(|ty| { quote! { std::sync::Arc::new(<#ty as std::default::Default>::default()) as std::sync::Arc } }); - + let guard_tokens = if doc_meta.requires_auth || !doc_meta.required_roles.is_empty() { quote! { vec![#(#guard_inits,)* #auth_guard_init #role_guard_init] } } else { quote! { vec![#(#guard_inits),*] } }; - + let version_tokens = if let Some(version) = &version { let lit = LitStr::new(version, method.sig.ident.span()); quote! { Some(#lit) } @@ -1797,7 +1797,9 @@ fn ensure_derive_trait(attrs: &mut Vec, trait_name: &str) { continue; } - let Ok(mut derives) = attr.parse_args_with(Punctuated::::parse_terminated) else { + let Ok(mut derives) = + attr.parse_args_with(Punctuated::::parse_terminated) + else { continue; }; @@ -2021,3 +2023,41 @@ fn is_option_numeric_type(ty: &Type) -> bool { }; is_numeric_type(inner_ty) } + +#[proc_macro_derive(Config)] +pub fn derive_config(item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as DeriveInput); + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = &input.generics.split_for_impl(); + + let Data::Struct(_data) = &input.data else { + return syn::Error::new(input.ident.span(), "Config can only be derived on structs") + .to_compile_error() + .into(); + }; + + let expanded = quote! { + impl #impl_generics nestforge_config::FromEnv for #name #ty_generics #where_clause { + fn from_env(env: &nestforge_config::EnvStore) -> Result { + std::compile_error!( + "Config derive requires manual FromEnv implementation. \ + Use `impl FromEnv for YourConfig` with `env.get(\"KEY\")` to read values." + ); + } + + fn config_key() -> &'static str { + stringify!(#name) + } + } + + impl #impl_generics std::default::Default for #name #ty_generics #where_clause { + fn default() -> Self { + std::compile_error!( + "Config derive requires manual Default implementation or provide default values." + ); + } + } + }; + + TokenStream::from(expanded) +} diff --git a/crates/nestforge/src/lib.rs b/crates/nestforge/src/lib.rs index 9fe61d6..21ba958 100644 --- a/crates/nestforge/src/lib.rs +++ b/crates/nestforge/src/lib.rs @@ -10,7 +10,7 @@ pub use nestforge_core::{ ApiEnvelopeResult, ApiResult, ApiSerializedResult, AuthIdentity, AuthUser, BearerToken, Body, Container, ContainerError, ControllerBasePath, ControllerDefinition, Cookies, Decorated, DocumentedController, DynamicModuleBuilder, ExceptionFilter, Guard, Headers, HttpException, - Identifiable, InMemoryStore, Injectable, InitializedModule, Inject, Interceptor, + Identifiable, InMemoryStore, InitializedModule, Inject, Injectable, Interceptor, IntoInjectableResult, LifecycleHook, List, ModuleDefinition, ModuleGraphEntry, ModuleGraphReport, ModuleRef, NextFn, NextFuture, OpenApiSchema, OpenApiSchemaComponent, OptionHttpExt, OptionalAuthUser, Param, Pipe, PipedBody, PipedParam, PipedQuery, Provider, @@ -28,8 +28,8 @@ pub use nestforge_cache::{ }; #[cfg(feature = "config")] pub use nestforge_config::{ - load_config, ConfigError, ConfigModule, ConfigOptions, EnvSchema, EnvStore, EnvValidationIssue, - FromEnv, + load_config, Config, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvSchema, + EnvStore, EnvValidationIssue, FromEnv, }; #[cfg(feature = "data")] pub use nestforge_data::{CacheStore, DataError, DataFuture, DocumentRepo}; @@ -38,9 +38,9 @@ pub use nestforge_db::{Db, DbConfig, DbError, DbTransaction}; pub use nestforge_http::NestForgeFactory; pub use nestforge_http::{MiddlewareConsumer, MiddlewareRoute, NestMiddleware}; pub use nestforge_macros::{ - authenticated, controller, delete, description, dto, entity, entity_dto, get, id, - identifiable, injectable, module, post, put, response, response_dto, roles, routes, summary, - tag, use_exception_filter, use_guard, use_interceptor, version, Identifiable, Validate, + authenticated, controller, delete, description, dto, entity, entity_dto, get, id, identifiable, + injectable, module, post, put, response, response_dto, roles, routes, summary, tag, + use_exception_filter, use_guard, use_interceptor, version, Identifiable, Validate, }; #[cfg(feature = "microservices")] pub use nestforge_microservices::{ @@ -269,26 +269,26 @@ pub use nestforge_websockets::{ pub mod prelude { pub use crate::{ - authenticated, controller, delete, dto, entity, entity_dto, get, identifiable, - injectable, module, post, put, response, response_dto, routes, summary, tag, - use_exception_filter, use_guard, use_interceptor, version, ApiSerializedResult, - HttpException, Inject, NestForgeFactory, Param, Query, Serialized, Validate, + authenticated, controller, delete, dto, entity, entity_dto, get, identifiable, injectable, + module, post, put, response, response_dto, routes, summary, tag, use_exception_filter, + use_guard, use_interceptor, version, ApiSerializedResult, HttpException, Inject, + NestForgeFactory, Param, Query, Serialized, Validate, }; + #[cfg(feature = "openapi")] + pub use crate::NestForgeFactoryOpenApiExt; + #[cfg(feature = "websockets")] + pub use crate::NestForgeFactoryWebSocketExt; + #[cfg(feature = "grpc")] + pub use crate::NestForgeGrpcFactory; + #[cfg(all(feature = "microservices", feature = "testing"))] + pub use crate::TestFactory; #[cfg(feature = "config")] pub use crate::{ConfigModule, ConfigOptions, FromEnv}; #[cfg(feature = "graphql")] pub use crate::{GraphQlConfig, NestForgeFactoryGraphQlExt}; - #[cfg(feature = "grpc")] - pub use crate::NestForgeGrpcFactory; #[cfg(feature = "microservices")] pub use crate::{MicroserviceClient, TransportMetadata}; - #[cfg(all(feature = "microservices", feature = "testing"))] - pub use crate::TestFactory; - #[cfg(feature = "openapi")] - pub use crate::NestForgeFactoryOpenApiExt; - #[cfg(feature = "websockets")] - pub use crate::NestForgeFactoryWebSocketExt; } #[cfg(feature = "openapi")] diff --git a/docs/config-module.md b/docs/config-module.md index 7fd24fa..1326139 100644 --- a/docs/config-module.md +++ b/docs/config-module.md @@ -1,57 +1,220 @@ -# Config Module +# Configuration Module -NestForge config support is in `nestforge-config` and re-exported via `nestforge` with the `config` feature. +NestForge provides a NestJS-inspired configuration system via the `nestforge-config` crate. -## Main Types +## Installation -- `ConfigModule` -- `ConfigOptions` -- `EnvSchema` -- `FromEnv` +The config module is included by default in the `nestforge` crate. -## Typical Usage +## Quick Start + +The easiest way to get started is to use the CLI to scaffold your project: + +```bash +nestforge new my-app +``` + +This generates an `app_config.rs` file for you. + +## Manual Setup + +### 1. Create app_config.rs ```rust -fn load_app_config() -> anyhow::Result { - let levels = vec!["trace", "debug", "info", "warn", "error"]; - let schema = nestforge::EnvSchema::new() - .required("APP_NAME") - .min_len("APP_NAME", 2) - .one_of("LOG_LEVEL", &levels); +// src/app_config.rs +use nestforge_config::{ConfigService, ConfigModule}; + +pub type AppConfig = ConfigService; - Ok(nestforge::ConfigModule::for_root::( - nestforge::ConfigOptions::new().env_file(".env").validate_with(schema), - )?) +pub fn load_config() -> AppConfig { + ConfigModule::for_root_with_options(ConfigModule::for_root().env_file(".env")) } ``` -## FromEnv +### 2. Register in AppModule -Your config struct implements `FromEnv`: +```rust +// src/app_module.rs +use nestforge::module; +use crate::app_config::{AppConfig, load_config}; + +#[module( + providers = [ + load_config() => AppConfig + ], + exports = [AppConfig] +)] +pub struct AppModule; +``` + +## Usage + +### In Services + +Inject `Config` and use the intuitive getter methods: ```rust -impl nestforge::FromEnv for AppConfig { - fn from_env(env: &nestforge::EnvStore) -> Result { - Ok(Self { - app_name: env.get("APP_NAME").unwrap_or("NestForge").to_string(), - log_level: env.get("LOG_LEVEL").unwrap_or("info").to_string(), - }) +use nestforge::prelude::*; + +pub struct AppService { + pub config: Config, +} + +impl AppService { + pub fn get_app_name(&self) -> String { + self.config.get_string_or("APP_NAME", "My App") + } + + pub fn get_port(&self) -> u16 { + self.config.get_u16_or("PORT", 3000) + } + + pub fn is_debug(&self) -> bool { + self.config.get_bool_or("DEBUG", false) } } ``` -## Validation Rules +## Getter Methods + +The ConfigService provides type-safe getters with optional defaults: + +| Method | Description | Default if missing | +|--------|-------------|-------------------| +| `get_string("KEY")` | Get string value | Empty string `""` | +| `get_string_or("KEY", "default")` | Get with default | `"default"` | +| `get_u16("KEY")` | Get unsigned 16-bit int | `0` | +| `get_u16_or("KEY", 3000)` | Get with default | `3000` | +| `get_u32("KEY")` | Get unsigned 32-bit int | `0` | +| `get_u32_or("KEY", 3000)` | Get with default | `3000` | +| `get_i32("KEY")` | Get signed 32-bit int | `0` | +| `get_i32_or("KEY", -1)` | Get with default | `-1` | +| `get_bool("KEY")` | Get boolean | `false` | +| `get_bool_or("KEY", true)` | Get with default | `true` | +| `get("KEY")` | Get as `Option<&str>` | `None` | +| `has("KEY")` | Check if key exists | `false` | + +## Environment File + +By default, `ConfigModule::for_root().env_file(".env")` loads from a `.env` file in your project root. + +Create a `.env` file: + +```bash +APP_NAME=My NestForge App +PORT=3000 +DEBUG=true +DATABASE_URL=postgres://user:pass@localhost/mydb +``` + +## Multiple Config Files -`EnvSchema` supports: +For feature-specific configs, use `ConfigModule::for_feature()`: + +```rust +// src/config/database.rs +use nestforge_config::{ConfigService, ConfigModule}; -- `.required("KEY")` -- `.min_len("KEY", min)` -- `.one_of("KEY", &[...])` +pub type DatabaseConfig = ConfigService; -When validation fails, startup returns `ConfigError::Validation` with issue details. +pub fn load_database_config() -> DatabaseConfig { + ConfigModule::for_root_with_options( + ConfigModule::for_feature().env_file(".env.database") + ) +} +``` -## Env Sources +## Typed Configs -By default, options include process env + `.env` file values. +For strongly-typed configuration structures, use `register_config`: -You can customize this with `ConfigOptions`. +```rust +use nestforge_config::register_config; + +#[derive(Debug, Clone)] +pub struct DatabaseSettings { + pub host: String, + pub port: u16, + pub username: String, + pub password: String, +} + +pub static DATABASE_CONFIG = register_config("database", || DatabaseSettings { + host: "localhost".to_string(), + port: 5432, + username: "postgres".to_string(), + password: "password".to_string(), +}); + +// Usage +let db = DATABASE_CONFIG.load(); +println!("Host: {}", db.host); +``` + +## Options + +### Custom .env Path + +```rust +ConfigModule::for_root_with_options( + ConfigModule::for_root().env_file(".env.production") +) +``` + +### Skip Process Environment + +```rust +ConfigModule::for_root_with_options( + ConfigModule::for_root().without_process_env() +) +``` + +## Example .env File + +```bash +# Application +APP_NAME=NestForge Application +PORT=3000 +HOST=127.0.0.1 + +# Database +DATABASE_URL=postgres://postgres:password@localhost:5432/mydb + +# Features +DEBUG=true +ENABLE_CORS=true + +# API Keys +API_KEY=your-api-key-here +``` + +## NestJS Comparison + +If you're coming from NestJS, here's how our API compares: + +| NestJS | NestForge | +|--------|-----------| +| `ConfigModule.forRoot({ isGlobal: true })` | `ConfigModule::for_root()` | +| `configService.get('KEY')` | `config.get_string("KEY")` | +| `configService.getOrThrow('KEY')` | `config.get("KEY").unwrap()` | +| `registerAs('config', () => ({ ... }))` | `register_config("name", \|\| Config { ... })` | +| `@Inject(ConfigService)` | `Config` | + +## Error Handling + +If loading the `.env` file fails, the application will panic with a clear error message: + +```rust +pub fn load_config() -> AppConfig { + ConfigModule::for_root_with_options(ConfigModule::for_root().env_file(".env")) + // Panics if .env file cannot be read +} +``` + +For custom error handling: + +```rust +pub fn load_config() -> Result { + ConfigModule::for_root_with_options(ConfigModule::for_root().env_file(".env")) +} +``` diff --git a/examples/hello-nestforge-graphql/src/app_config.rs b/examples/hello-nestforge-graphql/src/app_config.rs index f801c22..4d1fb91 100644 --- a/examples/hello-nestforge-graphql/src/app_config.rs +++ b/examples/hello-nestforge-graphql/src/app_config.rs @@ -1,4 +1,6 @@ -use nestforge::{injectable, ConfigModule, ConfigOptions}; +use nestforge::{ + injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, +}; #[injectable(factory = load_app_config)] pub struct AppConfig { @@ -6,18 +8,15 @@ pub struct AppConfig { } fn load_app_config() -> anyhow::Result { - let allowed_levels = vec!["trace", "debug", "info", "warn", "error"]; - let schema = nestforge::EnvSchema::new() - .min_len("APP_NAME", 2) - .one_of("LOG_LEVEL", &allowed_levels); - - Ok(ConfigModule::for_root::( - ConfigOptions::new().env_file(".env").validate_with(schema), - )?) + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::for_root_with_options(options); + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge GraphQL"), + }) } -impl nestforge::FromEnv for AppConfig { - fn from_env(env: &nestforge::EnvStore) -> Result { +impl FromEnv for AppConfig { + fn from_env(env: &EnvStore) -> Result { Ok(Self { app_name: env .get("APP_NAME") diff --git a/examples/hello-nestforge-grpc/src/app_config.rs b/examples/hello-nestforge-grpc/src/app_config.rs index 8cd38e8..4cf5db3 100644 --- a/examples/hello-nestforge-grpc/src/app_config.rs +++ b/examples/hello-nestforge-grpc/src/app_config.rs @@ -1,4 +1,6 @@ -use nestforge::{injectable, ConfigModule, ConfigOptions}; +use nestforge::{ + injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, +}; #[injectable(factory = load_app_config)] pub struct AppConfig { @@ -6,15 +8,15 @@ pub struct AppConfig { } fn load_app_config() -> anyhow::Result { - let schema = nestforge::EnvSchema::new().min_len("APP_NAME", 2); - - Ok(ConfigModule::for_root::( - ConfigOptions::new().env_file(".env").validate_with(schema), - )?) + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::for_root_with_options(options); + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge gRPC"), + }) } -impl nestforge::FromEnv for AppConfig { - fn from_env(env: &nestforge::EnvStore) -> Result { +impl FromEnv for AppConfig { + fn from_env(env: &EnvStore) -> Result { Ok(Self { app_name: env.get("APP_NAME").unwrap_or("NestForge gRPC").to_string(), }) diff --git a/examples/hello-nestforge-microservices/src/app_config.rs b/examples/hello-nestforge-microservices/src/app_config.rs index e29d995..2bb2a21 100644 --- a/examples/hello-nestforge-microservices/src/app_config.rs +++ b/examples/hello-nestforge-microservices/src/app_config.rs @@ -1,4 +1,6 @@ -use nestforge::{injectable, ConfigModule, ConfigOptions}; +use nestforge::{ + injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, +}; #[injectable(factory = load_app_config)] pub struct AppConfig { @@ -6,15 +8,15 @@ pub struct AppConfig { } fn load_app_config() -> anyhow::Result { - let schema = nestforge::EnvSchema::new().min_len("APP_NAME", 2); - - Ok(ConfigModule::for_root::( - ConfigOptions::new().env_file(".env").validate_with(schema), - )?) + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::for_root_with_options(options); + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge Microservices"), + }) } -impl nestforge::FromEnv for AppConfig { - fn from_env(env: &nestforge::EnvStore) -> Result { +impl FromEnv for AppConfig { + fn from_env(env: &EnvStore) -> Result { Ok(Self { app_name: env .get("APP_NAME") diff --git a/examples/hello-nestforge-websockets/src/app_config.rs b/examples/hello-nestforge-websockets/src/app_config.rs index 3bdc76d..01d3168 100644 --- a/examples/hello-nestforge-websockets/src/app_config.rs +++ b/examples/hello-nestforge-websockets/src/app_config.rs @@ -1,4 +1,6 @@ -use nestforge::{injectable, ConfigModule, ConfigOptions}; +use nestforge::{ + injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, +}; #[injectable(factory = load_app_config)] pub struct AppConfig { @@ -6,17 +8,20 @@ pub struct AppConfig { } fn load_app_config() -> anyhow::Result { - Ok(ConfigModule::for_root::( - ConfigOptions::new().env_file(".env"), - )?) + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::for_root_with_options(options); + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge WebSockets"), + }) } -impl nestforge::FromEnv for AppConfig { - fn from_env(env: &nestforge::EnvStore) -> Result { - let app_name = env - .get("APP_NAME") - .unwrap_or("NestForge WebSockets") - .to_string(); - Ok(Self { app_name }) +impl FromEnv for AppConfig { + fn from_env(env: &EnvStore) -> Result { + Ok(Self { + app_name: env + .get("APP_NAME") + .unwrap_or("NestForge WebSockets") + .to_string(), + }) } } diff --git a/examples/hello-nestforge/src/app_config.rs b/examples/hello-nestforge/src/app_config.rs index 9dad224..75568b8 100644 --- a/examples/hello-nestforge/src/app_config.rs +++ b/examples/hello-nestforge/src/app_config.rs @@ -1,4 +1,6 @@ -use nestforge::{injectable, ConfigModule, ConfigOptions}; +use nestforge::{ + injectable, ConfigError, ConfigModule, ConfigOptions, ConfigService, EnvStore, FromEnv, +}; #[injectable(factory = load_app_config)] pub struct AppConfig { @@ -7,23 +9,19 @@ pub struct AppConfig { } fn load_app_config() -> anyhow::Result { - let allowed_levels = vec!["trace", "debug", "info", "warn", "error"]; - let schema = nestforge::EnvSchema::new() - .min_len("APP_NAME", 2) - .one_of("LOG_LEVEL", &allowed_levels); - - Ok(ConfigModule::for_root::( - ConfigOptions::new().env_file(".env").validate_with(schema), - )?) + let options = ConfigOptions::new().env_file(".env"); + let config = ConfigModule::for_root_with_options(options); + Ok(AppConfig { + app_name: config.get_string_or("APP_NAME", "NestForge"), + log_level: config.get_string_or("LOG_LEVEL", "info"), + }) } -impl nestforge::FromEnv for AppConfig { - fn from_env(env: &nestforge::EnvStore) -> Result { - let app_name = env.get("APP_NAME").unwrap_or("NestForge").to_string(); - let log_level = env.get("LOG_LEVEL").unwrap_or("info").to_string(); +impl FromEnv for AppConfig { + fn from_env(env: &EnvStore) -> Result { Ok(Self { - app_name, - log_level, + app_name: env.get("APP_NAME").unwrap_or("NestForge").to_string(), + log_level: env.get("LOG_LEVEL").unwrap_or("info").to_string(), }) } }