|
|
@@ -0,0 +1,1078 @@
|
|
|
+# 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
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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.
|
|
|
+
|
|
|
+```vala
|
|
|
+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.
|
|
|
+
|
|
|
+```vala
|
|
|
+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.
|
|
|
+
|
|
|
+```vala
|
|
|
+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.
|
|
|
+
|
|
|
+```vala
|
|
|
+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`](src/Core/WebApplication.vala:12) to auto-register WebConfig:
|
|
|
+
|
|
|
+```vala
|
|
|
+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
|
|
|
+
|
|
|
+```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
|
|
|
+
|
|
|
+```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
|
|
|
+
|
|
|
+```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:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"`:
|
|
|
+
|
|
|
+```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
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "port": 3000
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## Usage Examples
|
|
|
+
|
|
|
+### In an Endpoint
|
|
|
+
|
|
|
+```vala
|
|
|
+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
|
|
|
+
|
|
|
+```vala
|
|
|
+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
|
|
|
+
|
|
|
+```bash
|
|
|
+# 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
|
|
|
+
|
|
|
+```bash
|
|
|
+# 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
|
|
|
+
|
|
|
+```bash
|
|
|
+# 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
|
|
|
+
|
|
|
+```bash
|
|
|
+# 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`](src/Core/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`](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` |
|