Configuration Management Guide

This guide explains how to use the configuration system in Armature, inspired by NestJS's @nestjs/config.

Overview

Armature provides a comprehensive configuration management system through the armature-config module. It supports multiple configuration sources, environment variables, validation, and seamless dependency injection integration.

Features

โœ… Environment Variables - Load from system environment โœ… .env File Support - Load from dotenv files โœ… Multiple Formats - JSON, TOML, and ENV files โœ… Type-Safe - Strongly typed configuration โœ… Validation - Built-in validation rules โœ… DI Integration - Injectable configuration service โœ… Prefix Support - Namespace environment variables โœ… Hierarchical - Nested configuration objects

Installation

Add the config feature to your Cargo.toml:

[dependencies]
armature-framework = { version = "0.1", features = ["config"] }
armature-config = "0.1"

Quick Start

1. Create Configuration Service

use armature_config::ConfigService;

let config = ConfigService::builder()
    .with_prefix("APP".to_string())
    .load_env()
    .load_dotenv(None)
    .build()?;

2. Access Configuration

// Get string value
let app_name = config.get_string("app.name")?;

// Get with default
let port = config.get_or("server.port", 3000);

// Get typed value
let debug: bool = config.get("app.debug")?;

3. Inject into Services

#[injectable]
#[derive(Clone)]
struct MyService {
    config: ConfigService,
}

impl MyService {
    fn do_something(&self) {
        let api_key = self.config.get_string("api.key").unwrap();
        // Use api_key...
    }
}

Configuration Sources

Environment Variables

Load from system environment:

let config = ConfigService::builder()
    .load_env()
    .build()?;

// Access
let value = config.get_string("path")?;

With Prefix:

// Loads APP_DATABASE_HOST as "database.host"
let config = ConfigService::builder()
    .with_prefix("APP".to_string())
    .load_env()
    .build()?;

.env Files

Create a .env file:

APP_NAME=My Application
APP_PORT=3000
DATABASE_HOST=localhost
DATABASE_PORT=5432

Load it:

let config = ConfigService::builder()
    .load_dotenv(None)  // Loads from .env
    .build()?;

// Or specify path
let config = ConfigService::builder()
    .load_dotenv(Some(".env.production".to_string()))
    .build()?;

JSON Files

Create config.json:

{
  "app": {
    "name": "My App",
    "port": 3000
  },
  "database": {
    "host": "localhost",
    "port": 5432
  }
}

Load it:

use armature_config::FileFormat;

let config = ConfigService::builder()
    .add_file("config.json".to_string(), FileFormat::Json)
    .build()?;

TOML Files

Create config.toml:

[app]
name = "My App"
port = 3000

[database]
host = "localhost"
port = 5432

Load it:

let config = ConfigService::builder()
    .add_file("config.toml".to_string(), FileFormat::Toml)
    .build()?;

Multiple Sources

Load from multiple sources (later sources override earlier ones):

let config = ConfigService::builder()
    .add_file("config.json".to_string(), FileFormat::Json)
    .load_dotenv(None)
    .load_env()
    .build()?;

Type-Safe Configuration

Define Configuration Structs

use serde::{Deserialize, Serialize};
use armature_config::Validate;

#[derive(Debug, Deserialize, Serialize)]
struct AppConfig {
    app: ApplicationConfig,
    database: DatabaseConfig,
}

#[derive(Debug, Deserialize, Serialize)]
struct ApplicationConfig {
    name: String,
    port: u16,
    debug: bool,
}

#[derive(Debug, Deserialize, Serialize)]
struct DatabaseConfig {
    host: String,
    port: u16,
    username: String,
    password: String,
}

Load and Validate

// Implement validation
impl Validate for AppConfig {
    fn validate(&self) -> armature_config::Result<()> {
        armature_config::ConfigValidator::not_empty(&self.app.name, "app.name")?;
        armature_config::ConfigValidator::is_port(self.app.port, "app.port")?;
        Ok(())
    }
}

// Load and validate
let manager = config.manager();
let app_config: AppConfig = manager.load_validated()?;

Validation

Built-in Validators

use armature_config::ConfigValidator;

// Not empty
ConfigValidator::not_empty(&value, "field_name")?;

// Range check
ConfigValidator::in_range(port, 1, 65535, "port")?;

// One of allowed values
ConfigValidator::one_of(&env, &["dev", "prod"], "environment")?;

// URL validation
ConfigValidator::is_url(&api_url, "api_url")?;

// Email validation
ConfigValidator::is_email(&email, "email")?;

// Port validation
ConfigValidator::is_port(port, "port")?;

Custom Validation

impl Validate for MyConfig {
    fn validate(&self) -> armature_config::Result<()> {
        // Custom validation logic
        if self.min_value > self.max_value {
            return Err(ConfigError::ValidationError(
                "min_value must be less than max_value".to_string()
            ));
        }

        // Use built-in validators
        ConfigValidator::not_empty(&self.name, "name")?;
        ConfigValidator::in_range(self.timeout, 0, 3600, "timeout")?;

        Ok(())
    }
}

Accessing Configuration

Basic Access

// Get value (returns Result)
let name: String = config.get("app.name")?;

// Get with default
let port = config.get_or("app.port", 3000);

// Check if key exists
if config.has("feature.enabled") {
    // Key exists
}

Type-Specific Getters

// String
let name = config.get_string("app.name")?;

// Integer
let port = config.get_int("server.port")?;

// Boolean
let debug = config.get_bool("app.debug")?;

// Float
let ratio = config.get_float("app.ratio")?;

Nested Values

// Access nested configuration
let db_host = config.get_string("database.host")?;
let db_port = config.get_int("database.port")?;

// Or load as struct
#[derive(Deserialize)]
struct DatabaseConfig {
    host: String,
    port: i64,
}

let db_config: DatabaseConfig = config.get("database")?;

Dependency Injection

Register Config Service

#[injectable]
#[derive(Clone)]
struct ConfigService {
    // ConfigService is itself injectable
}

#[module(
    providers: [ConfigService],
    controllers: [AppController]
)]
struct AppModule;

Inject into Services

#[injectable]
#[derive(Clone)]
struct UserService {
    config: ConfigService,
}

impl UserService {
    fn connect_database(&self) {
        let host = self.config.get_string("database.host").unwrap();
        let port = self.config.get_int("database.port").unwrap();

        // Connect to database...
    }
}

Inject into Controllers

#[controller("/api")]
#[derive(Clone)]
struct ApiController {
    config: ConfigService,
}

impl ApiController {
    fn get_version(&self) -> Result<Json<String>, Error> {
        let version = self.config.get_string("app.version")
            .unwrap_or_else(|_| "unknown".to_string());
        Ok(Json(version))
    }
}

Best Practices

1. Use Environment-Specific Files

.env.development
.env.staging
.env.production
let env = std::env::var("ENV").unwrap_or("development".to_string());
let env_file = format!(".env.{}", env);

let config = ConfigService::builder()
    .load_dotenv(Some(env_file))
    .load_env()  // Override with system env vars
    .build()?;

2. Provide Defaults

let port = config.get_or("server.port", 3000);
let host = config.get_or("server.host", "0.0.0.0".to_string());

3. Validate Early

// Validate at startup
let app_config: AppConfig = config.manager().load_validated()?;

// Use validated config
let app = Application::create::<AppModule>().await;
// Configuration is available via the DI container

4. Use Type-Safe Configuration

// Define strong types
#[derive(Deserialize)]
struct ServerConfig {
    host: String,
    port: u16,
    #[serde(default = "default_workers")]
    workers: usize,
}

fn default_workers() -> usize { 4 }

// Load typed config
let server_config: ServerConfig = config.get("server")?;

5. Namespace with Prefixes

// All env vars start with APP_
let config = ConfigService::builder()
    .with_prefix("APP".to_string())
    .load_env()
    .build()?;

// APP_DATABASE_HOST becomes "database.host"

6. Keep Secrets Secure

// Load secrets from environment, not config files
let api_key = config.get_string("api.key")?;
let db_password = config.get_string("database.password")?;

// Don't commit .env files with secrets!
// Use .env.example instead

Configuration Patterns

Factory Pattern

struct DatabaseFactory;

impl DatabaseFactory {
    fn create(config: &ConfigService) -> Result<Database> {
        let host = config.get_string("database.host")?;
        let port = config.get_int("database.port")?;

        Database::connect(&host, port as u16)
    }
}

Feature Flags

#[derive(Deserialize)]
struct FeatureFlags {
    new_ui: bool,
    beta_features: bool,
    analytics: bool,
}

let features: FeatureFlags = config.get("features")?;

if features.new_ui {
    // Use new UI
}

Multi-Environment Setup

#[derive(Deserialize)]
struct Environment {
    name: String,      // "development", "production"
    debug: bool,
    log_level: String,
}

let env: Environment = config.get("environment")?;

match env.name.as_str() {
    "production" => setup_production(),
    "development" => setup_development(),
    _ => setup_default(),
}

Testing

Mock Configuration

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_with_mock_config() {
        let config = ConfigService::new();
        config.manager().set("test.value", "mock_value").unwrap();

        let service = MyService { config };
        assert_eq!(service.get_test_value(), "mock_value");
    }
}

Test-Specific Config

#[tokio::test]
async fn test_api_endpoint() {
    let config = ConfigService::builder()
        .add_file("config.test.json".to_string(), FileFormat::Json)
        .build()
        .unwrap();

    // Test with config
}

Error Handling

use armature_config::ConfigError;

match config.get_string("required.key") {
    Ok(value) => println!("Value: {}", value),
    Err(ConfigError::KeyNotFound(key)) => {
        eprintln!("Missing required config: {}", key);
    }
    Err(ConfigError::ValidationError(msg)) => {
        eprintln!("Invalid config: {}", msg);
    }
    Err(e) => {
        eprintln!("Config error: {}", e);
    }
}

Advanced Usage

Merge Configurations

let base_config = ConfigService::builder()
    .add_file("config.base.json".to_string(), FileFormat::Json)
    .build()?;

let env_config = ConfigService::builder()
    .add_file("config.prod.json".to_string(), FileFormat::Json)
    .build()?;

base_config.manager().merge(env_config.manager())?;

Dynamic Configuration

// Update configuration at runtime
config.manager().set("feature.enabled", true)?;

// Check and update
if !config.has("cache.ttl") {
    config.manager().set("cache.ttl", 3600)?;
}

Configuration Watchers (Future)

// Watch for configuration changes
config.watch("config.json", |new_config| {
    println!("Configuration updated!");
});

Comparison with NestJS

NestJS

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: '.env',
      isGlobal: true,
    }),
  ],
})
export class AppModule {}

@Injectable()
export class AppService {
  constructor(private config: ConfigService) {}

  getPort(): number {
    return this.config.get<number>('PORT');
  }
}

Armature

#[injectable]
#[derive(Clone)]
struct AppService {
    config: ConfigService,
}

impl AppService {
    fn get_port(&self) -> i64 {
        self.config.get_int("port").unwrap()
    }
}

#[module(
    providers: [ConfigService, AppService],
    controllers: []
)]
struct AppModule;

Summary

Armature's configuration system provides:

โœ… Multiple Sources - Env, .env, JSON, TOML โœ… Type-Safe - Strong typing and validation โœ… DI Integration - Seamlessly injectable โœ… Flexible - Hierarchical and namespace support โœ… Production Ready - Validation and error handling โœ… NestJS-Like - Familiar patterns for NestJS users

For complete examples, see examples/config_example.rs in the repository.