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.
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
The layering system processes configurations in a specific order:
web-config.json from CWD as the foundationASTRALIS_CONFIG_PATH for semicolon-delimited override pathsWebConfig instance registered in the DI containerclassDiagram
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
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
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 are replaced entirely by the override value.
Base: { "port": 8080, "host": "localhost" }
Override: { "port": 3000 }
Result: { "port": 3000, "host": "localhost" }
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 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 } }
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
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 }
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
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;
}
}
}
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());
}
}
}
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 });
}
}
}
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();
}
}
}
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
}
The power of configuration layering is best demonstrated with a multi-environment setup:
{
"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
}
}
{
"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"]
}
}
{
"debug": true,
"database": {
"host": "localhost",
"user": "devuser"
},
"logging": {
"level": "debug",
"format": "text"
}
}
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
}
}
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
}
}
{
"port": 3000
}
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");
}
}
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();
}
# 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
# Developer wants to override database settings locally
ASTRALIS_CONFIG_PATH="web-config.local.json" ./myapp
This loads:
web-config.json (base)web-config.local.json (local overrides)# CI uses staging config with test database override
ASTRALIS_CONFIG_PATH="web-config.staging.json;web-config.test.json" ./run_tests
This loads:
web-config.json (base)web-config.staging.json (staging settings)web-config.test.json (test database, mock services)# 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.
WebConfigError.PARSE_ERRORWebConfigError.PARSE_ERRORWebConfig is registered as a singleton because:
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.
meson.build:13);) is the standard path separator on Windows (e.g., PATH variable):) is used in Unix PATH but conflicts with absolute paths on Windows (C:\...)ASTRALIS_CONFIG_PATH follows the existing convention established by ASTRALIS_PORT.
${VAR} syntax in config valuesThe 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 |