web-config-architecture.md 34 KB

WebConfig Configuration System Architecture

Overview

This document describes the architecture for a JSON-based configuration system for Astralis. The system reads a web-config.json file and makes configuration values available through the Dependency Injection container.

The system supports configuration layering, allowing multiple configuration files to be merged with later files overriding values from earlier files. This enables environment-specific configurations without duplicating common settings.

Design Goals

  1. DI Integration: Configuration should be injectable like other dependencies
  2. Flexible Discovery: Support both explicit path via environment variable and default location
  3. Type-Safe Access: Provide typed getters with default values
  4. Minimal Dependencies: Leverage existing json-glib-1.0 dependency
  5. Consistent Patterns: Follow existing Astralis conventions
  6. Configuration Layering: Support merging multiple config files with override semantics

Architecture Overview

flowchart TB
    subgraph Discovery[Configuration Layering]
        A[Check ASTRALIS_CONFIG_PATH] -->|Path set| B[Parse semicolon-delimited paths]
        A -->|Not set| C[Check CWD for web-config.json]
        C -->|Found| D[Load base config from CWD]
        C -->|Not found| E[Use empty config]
        B --> F[Load base: web-config.json]
        F --> G[Merge override 1]
        G --> H[Merge override 2]
        H --> I[... Merge override N]
        I --> J[Final merged WebConfig]
        D --> J
        E --> K[Empty WebConfig]
        J --> L[Register in DI Container]
        K --> L
    end
    
    subgraph DI[Dependency Injection]
        M[WebApplication] -->|register_singleton| N[Container]
        L --> N
        N -->|inject| O[Endpoint/Component]
    end
    
    Discovery --> DI

Configuration Layering Flow

The layering system processes configurations in a specific order:

  1. Base Configuration: Always load web-config.json from CWD as the foundation
  2. Override Configurations: Parse ASTRALIS_CONFIG_PATH for semicolon-delimited override paths
  3. Sequential Merging: Apply each override file in order, with later files taking precedence
  4. Result: Single merged WebConfig instance registered in the DI container

Component Design

Class Diagram

classDiagram
    class WebConfig {
        -Json.Object root_object
        -bool loaded
        -string[] loaded_files
        +WebConfig.from_json_object(Json.Object obj, string[] sources)
        +WebConfig.empty()
        +get_string(string key, string default) string
        +get_int(string key, int default) int
        +get_bool(string key, bool default) bool
        +get_string_array(string key, string[] default) string[]
        +has_key(string key) bool
        +get_section(string key) WebConfigSection?
        +is_loaded() bool
        +get_loaded_files() string[]
    }
    
    class WebConfigSection {
        -Json.Object section_object
        +get_string(string key, string default) string
        +get_int(string key, int default) int
        +get_bool(string key, bool default) bool
        +get_string_array(string key, string[] default) string[]
        +has_key(string key) bool
        +get_section(string key) WebConfigSection?
    }
    
    class WebConfigLoader {
        +const DEFAULT_CONFIG_FILENAME string
        +const ENV_CONFIG_PATH string
        +const PATH_SEPARATOR string
        +static load() WebConfig
        +static discover_config_path() string?
        +static parse_override_paths(string env_value) string[]
    }
    
    class ConfigMerger {
        +static merge_objects(Json.Object base, Json.Object override) Json.Object
        +static deep_merge(Json.Object base, Json.Object override) Json.Object
        -static merge_value(Json.Node base_node, Json.Node override_node) Json.Node
    }
    
    WebConfig --> WebConfigSection : creates
    WebConfigLoader --> WebConfig : creates
    WebConfigLoader --> ConfigMerger : uses
    ConfigMerger --> WebConfig : produces merged config

File Structure

src/
├── Core/
│   ├── WebConfig.vala           # Public: Configuration access class
│   ├── WebConfigSection.vala    # Public: Nested section access
│   ├── WebConfigLoader.vala     # Public: File discovery and loading with layering
│   └── ConfigMerger.vala        # Internal: Deep merge logic for config objects
└── meson.build                  # Modified: add new files

Merge Semantics

The configuration layering system uses a deep merge strategy for combining multiple configuration files. Understanding these semantics is crucial for predictable override behavior.

Scalar Values (Strings, Numbers, Booleans, Null)

Scalar values are replaced entirely by the override value.

Base:        { "port": 8080, "host": "localhost" }
Override:    { "port": 3000 }
Result:      { "port": 3000, "host": "localhost" }

Arrays

Arrays are replaced entirely by the override array (not concatenated).

Base:        { "allowed_hosts": ["localhost", "127.0.0.1"] }
Override:    { "allowed_hosts": ["example.com"] }
Result:      { "allowed_hosts": ["example.com"] }

Rationale: Array replacement provides predictable behavior. Concatenation could lead to unintended accumulation of values across environments. If array extension is needed, specify the complete array in the override.

Nested Objects (Deep Merge)

Nested objects are merged recursively, applying the same rules at each level.

Base:        { "database": { "host": "localhost", "port": 5432, "pool_size": 10 } }
Override:    { "database": { "host": "prod-db.example.com", "pool_size": 20 } }
Result:      { "database": { "host": "prod-db.example.com", "port": 5432, "pool_size": 20 } }

Null Values

Setting a value to null in an override removes the key from the merged result.

Base:        { "debug": true, "feature_x": "enabled" }
Override:    { "debug": null }
Result:      { "feature_x": "enabled" }  // debug key removed

Missing Keys

Keys present in base but absent in override are preserved.

Base:        { "a": 1, "b": 2, "c": 3 }
Override:    { "b": 20 }
Result:      { "a": 1, "b": 20, "c": 3 }

Merge Algorithm Pseudocode

function deep_merge(base_obj, override_obj):
    result = copy(base_obj)
    
    for each key, value in override_obj:
        if value is null:
            remove key from result
        else if key not in result:
            result[key] = copy(value)
        else if both result[key] and value are objects:
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = copy(value)  // Replace scalars and arrays
    
    return result

Detailed Component Specifications

1. WebConfig - Core/WebConfig.vala

The main configuration class that holds parsed JSON values. Tracks source files for debugging.

using Json;

namespace Astralis {

    public errordomain WebConfigError {
        FILE_NOT_FOUND,
        PARSE_ERROR,
        INVALID_JSON
    }

    public class WebConfig : Object {

        private Json.Object? root_object;
        private bool loaded;
        private string[] loaded_files;

        internal WebConfig.from_json_object(Json.Object? obj, string[] sources = {}) {
            this.root_object = obj;
            this.loaded = obj != null;
            this.loaded_files = sources;
        }

        public WebConfig.empty() {
            this.root_object = null;
            this.loaded = false;
            this.loaded_files = {};
        }

        // String access
        public string get_string(string key, string default = "") {
            if (root_object == null || !root_object.has_member(key)) {
                return default;
            }
            var node = root_object.get_member(key);
            if (node.get_node_type() != NodeType.VALUE) {
                return default;
            }
            return node.get_string() ?? default;
        }

        // Integer access
        public int get_int(string key, int default = 0) {
            if (root_object == null || !root_object.has_member(key)) {
                return default;
            }
            var node = root_object.get_member(key);
            if (node.get_node_type() != NodeType.VALUE) {
                return default;
            }
            return (int) node.get_int();
        }

        // Boolean access
        public bool get_bool(string key, bool default = false) {
            if (root_object == null || !root_object.has_member(key)) {
                return default;
            }
            var node = root_object.get_member(key);
            if (node.get_node_type() != NodeType.VALUE) {
                return default;
            }
            return node.get_boolean();
        }

        // String array access
        public string[] get_string_array(string key, string[] default = {}) {
            if (root_object == null || !root_object.has_member(key)) {
                return default;
            }
            var node = root_object.get_member(key);
            if (node.get_node_type() != NodeType.ARRAY) {
                return default;
            }
            var array = node.get_array();
            var result = new string[array.get_length()];
            for (uint i = 0; i < array.get_length(); i++) {
                result[i] = array.get_string_element(i) ?? "";
            }
            return result;
        }

        // Check if key exists
        public bool has_key(string key) {
            return root_object != null && root_object.has_member(key);
        }

        // Get nested section
        public WebConfigSection? get_section(string key) {
            if (root_object == null || !root_object.has_member(key)) {
                return null;
            }
            var node = root_object.get_member(key);
            if (node.get_node_type() != NodeType.OBJECT) {
                return null;
            }
            return new WebConfigSection(node.get_object());
        }

        // Check if config was loaded from file
        public bool is_loaded() {
            return loaded;
        }

        // Get list of files that contributed to this config (for debugging)
        public string[] get_loaded_files() {
            return loaded_files;
        }
    }
}

2. WebConfigSection - Core/WebConfigSection.vala

Provides access to nested configuration sections.

using Json;

namespace Astralis {

    public class WebConfigSection : Object {

        private Json.Object section_object;

        internal WebConfigSection(Json.Object obj) {
            this.section_object = obj;
        }

        public string get_string(string key, string default = "") {
            if (!section_object.has_member(key)) {
                return default;
            }
            var node = section_object.get_member(key);
            if (node.get_node_type() != NodeType.VALUE) {
                return default;
            }
            return node.get_string() ?? default;
        }

        public int get_int(string key, int default = 0) {
            if (!section_object.has_member(key)) {
                return default;
            }
            var node = section_object.get_member(key);
            if (node.get_node_type() != NodeType.VALUE) {
                return default;
            }
            return (int) node.get_int();
        }

        public bool get_bool(string key, bool default = false) {
            if (!section_object.has_member(key)) {
                return default;
            }
            var node = section_object.get_member(key);
            if (node.get_node_type() != NodeType.VALUE) {
                return default;
            }
            return node.get_boolean();
        }

        public string[] get_string_array(string key, string[] default = {}) {
            if (!section_object.has_member(key)) {
                return default;
            }
            var node = section_object.get_member(key);
            if (node.get_node_type() != NodeType.ARRAY) {
                return default;
            }
            var array = node.get_array();
            var result = new string[array.get_length()];
            for (uint i = 0; i < array.get_length(); i++) {
                result[i] = array.get_string_element(i) ?? "";
            }
            return result;
        }

        public bool has_key(string key) {
            return section_object.has_member(key);
        }

        public WebConfigSection? get_section(string key) {
            if (!section_object.has_member(key)) {
                return null;
            }
            var node = section_object.get_member(key);
            if (node.get_node_type() != NodeType.OBJECT) {
                return null;
            }
            return new WebConfigSection(node.get_object());
        }
    }
}

3. WebConfigLoader - Core/WebConfigLoader.vala

Handles file discovery, layering, and parsing. Supports semicolon-delimited override paths.

using Json;

namespace Astralis {

    public class WebConfigLoader : Object {

        public const string DEFAULT_CONFIG_FILENAME = "web-config.json";
        public const string ENV_CONFIG_PATH = "ASTRALIS_CONFIG_PATH";
        public const string PATH_SEPARATOR = ";";

        public static WebConfig load() {
            string[] config_paths = discover_config_paths();
            
            if (config_paths.length == 0) {
                printerr(@"[Astralis] No config file found, using empty configuration\n");
                return new WebConfig.empty();
            }

            return load_and_merge_files(config_paths);
        }

        /// Discovers all configuration paths in priority order:
        /// 1. Base config from CWD (web-config.json)
        /// 2. Override configs from ASTRALIS_CONFIG_PATH (semicolon-delimited)
        public static string[] discover_config_paths() {
            var paths = new GenericArray<string>();

            // Always start with base config from CWD if it exists
            string cwd_path = Path.build_filename(
                Environment.get_current_dir(),
                DEFAULT_CONFIG_FILENAME
            );
            bool has_base = File.new_for_path(cwd_path).query_exists();
            if (has_base) {
                paths.add(cwd_path);
            }

            // Check environment variable for override paths
            string? env_path = Environment.get_variable(ENV_CONFIG_PATH);
            if (env_path != null && env_path != "") {
                string[] override_paths = parse_override_paths(env_path);
                foreach (string override_path in override_paths) {
                    if (File.new_for_path(override_path).query_exists()) {
                        paths.add(override_path);
                    } else {
                        printerr(@"[Astralis] Warning: Override config not found: $override_path\n");
                    }
                }
            }

            // Return empty array if no base and no valid overrides
            if (paths.length == 0) {
                return {};
            }

            // If we have overrides but no base, first override becomes base
            // (This handles the case where ASTRALIS_CONFIG_PATH is set but no CWD config exists)
            
            var result = new string[paths.length];
            for (int i = 0; i < paths.length; i++) {
                result[i] = paths[i];
            }
            return result;
        }

        /// Parses semicolon-delimited paths from environment variable
        /// Handles edge cases: empty segments, whitespace, relative vs absolute paths
        public static string[] parse_override_paths(string env_value) {
            if (env_value == null || env_value.strip() == "") {
                return {};
            }

            string[] raw_paths = env_value.split(PATH_SEPARATOR);
            var valid_paths = new GenericArray<string>();

            foreach (unowned string raw_path in raw_paths) {
                string trimmed = raw_path.strip();
                if (trimmed == "") {
                    continue;  // Skip empty segments
                }

                // Resolve relative paths against CWD
                string resolved_path;
                if (Path.is_absolute(trimmed)) {
                    resolved_path = trimmed;
                } else {
                    resolved_path = Path.build_filename(
                        Environment.get_current_dir(),
                        trimmed
                    );
                }
                valid_paths.add(resolved_path);
            }

            var result = new string[valid_paths.length];
            for (int i = 0; i < valid_paths.length; i++) {
                result[i] = valid_paths[i];
            }
            return result;
        }

        /// Loads and merges multiple configuration files
        /// Files are processed in order; later files override earlier ones
        private static WebConfig load_and_merge_files(string[] paths) {
            if (paths.length == 0) {
                return new WebConfig.empty();
            }

            Json.Object? merged_object = null;
            var loaded_files = new GenericArray<string>();

            foreach (string path in paths) {
                try {
                    Json.Object config_object = load_json_object(path);
                    loaded_files.add(path);

                    if (merged_object == null) {
                        merged_object = config_object;
                    } else {
                        merged_object = ConfigMerger.deep_merge(merged_object, config_object);
                    }
                    printerr(@"[Astralis] Loaded config: $path\n");

                } catch (WebConfigError e) {
                    printerr(@"[Astralis] Error loading config '$path': $(e.message)\n");
                    // Continue processing other files; don't fail entirely
                }
            }

            if (merged_object == null) {
                return new WebConfig.empty();
            }

            var sources = new string[loaded_files.length];
            for (int i = 0; i < loaded_files.length; i++) {
                sources[i] = loaded_files[i];
            }
            return new WebConfig.from_json_object(merged_object, sources);
        }

        /// Loads a single JSON file and returns its root object
        public static Json.Object load_json_object(string path) throws WebConfigError {
            try {
                string contents;
                FileUtils.get_contents(path, out contents);

                var parser = new Json.Parser();
                if (!parser.load_from_data(contents)) {
                    throw new WebConfigError.PARSE_ERROR(
                        "Failed to parse JSON from config file"
                    );
                }

                var root = parser.get_root();
                if (root.get_node_type() != NodeType.OBJECT) {
                    throw new WebConfigError.INVALID_JSON(
                        "Config file root must be a JSON object"
                    );
                }

                return root.get_object();

            } catch (FileError.NOENT e) {
                throw new WebConfigError.FILE_NOT_FOUND(
                    @"Config file not found: $path"
                );
            } catch (FileError e) {
                throw new WebConfigError.PARSE_ERROR(
                    @"Error reading config file: $(e.message)"
                );
            } catch (Error e) {
                throw new WebConfigError.PARSE_ERROR(
                    @"Error parsing config file: $(e.message)"
                );
            }
        }

        // Backwards compatibility: Load single file
        public static WebConfig load_from_file(string path) throws WebConfigError {
            Json.Object obj = load_json_object(path);
            return new WebConfig.from_json_object(obj, { path });
        }
    }
}

4. ConfigMerger - Core/ConfigMerger.vala

Implements deep merge logic for combining JSON configuration objects.

using Json;

namespace Astralis {

    /// Internal class for merging JSON configuration objects
    internal class ConfigMerger : Object {

        /// Performs a deep merge of two JSON objects.
        /// The override object's values take precedence.
        public static Json.Object deep_merge(Json.Object base, Json.Object override) {
            // Start with a copy of the base object
            var result = new Json.Object();
            
            // Copy all members from base
            foreach (string member_name in base.get_members()) {
                var node = copy_node(base.get_member(member_name));
                result.set_member(member_name, node);
            }

            // Merge override values
            foreach (string member_name in override.get_members()) {
                var override_node = override.get_member(member_name);
                
                // Handle null as deletion
                if (override_node.get_node_type() == NodeType.NULL) {
                    if (result.has_member(member_name)) {
                        result.remove_member(member_name);
                    }
                    continue;
                }

                // If key doesn't exist in result, just copy it
                if (!result.has_member(member_name)) {
                    result.set_member(member_name, copy_node(override_node));
                    continue;
                }

                // Get existing node for merging
                var base_node = result.get_member(member_name);
                var merged_node = merge_nodes(base_node, override_node);
                result.set_member(member_name, merged_node);
            }

            return result;
        }

        /// Merges two JSON nodes based on their types
        private static Json.Node merge_nodes(Json.Node base_node, Json.Node override_node) {
            var base_type = base_node.get_node_type();
            var override_type = override_node.get_node_type();

            // If types differ or either is not an object, override wins
            if (base_type != NodeType.OBJECT || override_type != NodeType.OBJECT) {
                return copy_node(override_node);
            }

            // Both are objects: recursive deep merge
            var merged_object = deep_merge(
                base_node.get_object(),
                override_node.get_object()
            );
            
            var result = new Json.Node(NodeType.OBJECT);
            result.set_object(merged_object);
            return result;
        }

        /// Creates a deep copy of a JSON node
        private static Json.Node copy_node(Json.Node source) {
            return source.copy();
        }
    }
}

5. WebApplication Integration

Modify WebApplication.vala to auto-register WebConfig:

public class WebApplication {

    public Container container { get; private set; }
    public int port { get; private set; }
    public WebConfig config { get; private set; }

    private Server server;
    private Pipeline pipeline;

    public WebApplication(int? port = null) {
        // Load configuration first (supports layering via ASTRALIS_CONFIG_PATH)
        this.config = WebConfigLoader.load();
        
        // Port priority: constructor arg > config file > env var > default
        int config_port = config.get_int("port", 0);
        string? env_port = Environment.get_variable("ASTRALIS_PORT");
        
        if (port != null) {
            this.port = port;
        } else if (config_port > 0) {
            this.port = config_port;
        } else if (env_port != null) {
            this.port = int.parse(env_port);
        } else {
            this.port = 8080;
        }
        
        // Log loaded config files for debugging
        var loaded_files = config.get_loaded_files();
        if (loaded_files.length > 0) {
            printerr(@"[Astralis] Configuration loaded from: $(string.joinv(" -> ", loaded_files))\n");
        }
        printerr(@"[Astralis] Web application using port $(this.port)\n");

        container = new Container();
        
        // Register config as singleton
        container.register_singleton<WebConfig>(() => config);
        
        pipeline = new Pipeline(container);
        server = new Server(this.port, pipeline);
    }

    // ... rest of the class unchanged
}

Example Configuration Files

Layered Configuration Example

The power of configuration layering is best demonstrated with a multi-environment setup:

Base Configuration - web-config.json

{
    "port": 8080,
    "host": "0.0.0.0",
    "debug": false,
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "myapp",
        "user": "appuser",
        "pool_size": 10
    },
    "logging": {
        "level": "info",
        "format": "json"
    },
    "cors": {
        "allowed_origins": ["http://localhost:3000"],
        "allow_credentials": true
    }
}

Staging Override - web-config.staging.json

{
    "debug": true,
    "database": {
        "host": "staging-db.internal.example.com",
        "pool_size": 20
    },
    "logging": {
        "level": "debug"
    },
    "cors": {
        "allowed_origins": ["https://staging.example.com", "https://staging-admin.example.com"]
    }
}

Local Development Override - web-config.local.json

{
    "debug": true,
    "database": {
        "host": "localhost",
        "user": "devuser"
    },
    "logging": {
        "level": "debug",
        "format": "text"
    }
}

Merged Result (Staging Environment)

When running with ASTRALIS_CONFIG_PATH="web-config.staging.json", the merged configuration is:

{
    "port": 8080,
    "host": "0.0.0.0",
    "debug": true,
    "database": {
        "host": "staging-db.internal.example.com",
        "port": 5432,
        "name": "myapp",
        "user": "appuser",
        "pool_size": 20
    },
    "logging": {
        "level": "debug",
        "format": "json"
    },
    "cors": {
        "allowed_origins": ["https://staging.example.com", "https://staging-admin.example.com"],
        "allow_credentials": true
    }
}

Merged Result (Local Development)

When running with ASTRALIS_CONFIG_PATH="web-config.staging.json;web-config.local.json":

{
    "port": 8080,
    "host": "0.0.0.0",
    "debug": true,
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "myapp",
        "user": "devuser",
        "pool_size": 20
    },
    "logging": {
        "level": "debug",
        "format": "text"
    },
    "cors": {
        "allowed_origins": ["https://staging.example.com", "https://staging-admin.example.com"],
        "allow_credentials": true
    }
}

Minimal Example

{
    "port": 3000
}

Usage Examples

In an Endpoint

using Astralis;

class ConfigAwareEndpoint : Object, Endpoint {
    
    // Inject WebConfig via constructor injection
    private WebConfig config;
    
    public ConfigAwareEndpoint(WebConfig config) {
        this.config = config;
    }
    
    public async HttpResult handle_request(HttpContext ctx, RouteContext route) throws Error {
        // Access top-level config values
        string host = config.get_string("host", "localhost");
        int port = config.get_int("port", 8080);
        bool debug = config.get_bool("debug", false);
        
        // Access nested sections
        var db_config = config.get_section("database");
        if (db_config != null) {
            string db_host = db_config.get_string("host", "localhost");
            int db_port = db_config.get_int("port", 5432);
        }
        
        // Access arrays
        var cors_config = config.get_section("cors");
        string[] origins = {};
        if (cors_config != null) {
            origins = cors_config.get_string_array("allowed_origins", {});
        }
        
        return new HttpStringResult(@"Config loaded: host=$host, port=$port");
    }
}

Manual Registration Override

void main() {
    var application = new WebApplication();
    
    // Access config directly from application
    var config = application.config;
    string app_name = config.get_string("app_name", "My App");
    
    // Register additional config-dependent services
    application.add_singleton<DatabaseConnection>(() => {
        var db_section = config.get_section("database");
        return new DatabaseConnection(
            db_section.get_string("host", "localhost"),
            db_section.get_int("port", 5432)
        );
    });
    
    application.run();
}

Using Environment Variable

# Single config file
export ASTRALIS_CONFIG_PATH=/etc/myapp/web-config.json
./myapp

# Multiple config files with layering (staging environment)
export ASTRALIS_CONFIG_PATH="web-config.staging.json"
./myapp

# Multiple config files with layering (local development)
export ASTRALIS_CONFIG_PATH="web-config.staging.json;web-config.local.json"
./myapp

# Absolute and relative paths mixed
export ASTRALIS_CONFIG_PATH="/etc/myapp/base.json;./local-overrides.json"
./myapp

# Production with secrets file
export ASTRALIS_CONFIG_PATH="/etc/myapp/web-config.json;/run/secrets/config.json"
./myapp

Layered Configuration Scenarios

Scenario 1: Development Environment

# Developer wants to override database settings locally
ASTRALIS_CONFIG_PATH="web-config.local.json" ./myapp

This loads:

  1. web-config.json (base)
  2. web-config.local.json (local overrides)

Scenario 2: CI/CD Pipeline

# CI uses staging config with test database override
ASTRALIS_CONFIG_PATH="web-config.staging.json;web-config.test.json" ./run_tests

This loads:

  1. web-config.json (base)
  2. web-config.staging.json (staging settings)
  3. web-config.test.json (test database, mock services)

Scenario 3: Production with Secrets Injection

# Production base config with secrets mounted by orchestration platform
ASTRALIS_CONFIG_PATH="/run/secrets/db-credentials.json;/run/secrets/api-keys.json" ./myapp

Note: In production, there may be no web-config.json in CWD; the first override becomes the base.

Error Handling Strategy

Missing Configuration File

  • Behavior: Application continues with empty configuration
  • Logging: Warning message printed to stderr
  • Rationale: Allows applications to run without config file using all defaults

Invalid JSON

  • Behavior: Throw WebConfigError.PARSE_ERROR
  • Logging: Error details printed to stderr
  • Rationale: Invalid JSON indicates a serious configuration problem

Missing Keys

  • Behavior: Return default value provided by caller
  • Logging: None (this is expected behavior)
  • Rationale: Configuration should be optional; code should provide sensible defaults

File Exists but Unreadable

  • Behavior: Throw WebConfigError.PARSE_ERROR
  • Logging: Error details printed to stderr
  • Rationale: Permission issues should be surfaced to the user

Implementation Steps

  1. Create WebConfigSection.vala - Nested section access class
  2. Create WebConfig.vala - Main configuration class with source tracking
  3. Create ConfigMerger.vala - Deep merge logic for layering
  4. Create WebConfigLoader.vala - File discovery, layering, and loading
  5. Update WebApplication.vala - Auto-register WebConfig singleton with layering support
  6. Update src/meson.build - Add new source files
  7. Create example - Demonstrate configuration usage with layering
  8. Add unit tests - Test merge semantics and layering behavior

Design Decisions and Rationale

Why Singleton Registration?

WebConfig is registered as a singleton because:

  • Configuration is read once at startup
  • All components should see the same configuration values
  • Consistent with how other cross-cutting concerns are handled

Why Not Throw on Missing File?

Following the existing pattern in WebApplication.vala:13 where ASTRALIS_PORT defaults gracefully, the configuration system should allow applications to run without a config file.

Why json-glib-1.0?

  • Already a project dependency (see meson.build:13)
  • Well-maintained GNOME library
  • Good Vala bindings
  • Consistent with project's GLib-based architecture

Why Separate Loader Class?

  • Single Responsibility Principle
  • Easier testing of file discovery logic
  • Allows custom loaders in the future
  • Keeps WebConfig focused on value access

Why Semicolon as Path Separator?

  • Semicolon (;) is the standard path separator on Windows (e.g., PATH variable)
  • Colon (:) is used in Unix PATH but conflicts with absolute paths on Windows (C:\...)
  • Semicolon is unambiguous on all platforms
  • Familiar to developers from Java classpath and other tools

Why Array Replacement Instead of Concatenation?

  • Predictable behavior: you know exactly what the final array contains
  • Concatenation could lead to unintended accumulation across environments
  • If you need to extend arrays, specify the complete array in the override
  • Future enhancement could add merge strategy hints if needed

Why Deep Merge for Objects?

  • Allows partial overrides of nested configuration (e.g., database settings)
  • Avoids the need to duplicate unchanged nested values
  • Matches behavior of popular configuration systems (Spring Boot, ASP.NET Core)
  • Maintains backward compatibility while enabling layering

Environment Variable Name

ASTRALIS_CONFIG_PATH follows the existing convention established by ASTRALIS_PORT.

Future Enhancements

  1. Hot Reload: Watch config file for changes and reload
  2. Schema Validation: Validate config against a JSON schema
  3. Environment Variable Substitution: Support ${VAR} syntax in config values
  4. Config Sections as DI Services: Register individual sections as separate services
  5. Array Merge Strategies: Allow configuration to specify array merge behavior (replace, concatenate, or merge-by-index)
  6. Config Diff Logging: Log which values were overridden by which file for debugging

Integration with Existing Patterns

The WebConfig system follows established Astralis patterns:

Pattern Example WebConfig Usage
Constructor injection Pipeline(Container container) Endpoint(WebConfig config)
Singleton registration container.register_singleton<T> Auto-registered in WebApplication
Graceful defaults ASTRALIS_PORT ?? "8080" get_string(key, default)
Internal constructors internal HttpContext(...) internal WebConfig.from_json_object(...)
Public interfaces HttpResult, Endpoint WebConfig, WebConfigSection