Skip to content

Core Resolvers

HoloConf ships with four essential resolvers that cover the most common configuration needs. Let's explore each one and see how they solve real-world problems.

Environment Variables

The most common use case for dynamic configuration is reading from environment variables. This lets you change settings between development, staging, and production without editing files.

Let's start with a simple example:

database:
  host: ${env:DB_HOST}
  port: ${env:DB_PORT}

Now try to use it:

from holoconf import Config

config = Config.load("config.yaml")
host = config.database.host
# Error: ResolverError: Environment variable DB_HOST is not set
use holoconf::Config;

let config = Config::load("config.yaml")?;
let host: String = config.get("database.host")?;
// Error: Environment variable DB_HOST is not set
$ holoconf get config.yaml database.host
Error: Environment variable DB_HOST is not set

The error is helpful - it prevents us from accidentally using undefined values. But we want our configuration to work during development without setting every variable. Let's add defaults:

database:
  host: ${env:DB_HOST,default=localhost}
  port: ${env:DB_PORT,default=5432}

Now it works both ways:

import os
from holoconf import Config

# Without environment variables - uses defaults
config = Config.load("config.yaml")
host = config.database.host
print(f"Host: {host}")
# Host: localhost

# With environment variables - uses env values
os.environ["DB_HOST"] = "prod-db.example.com"
config = Config.load("config.yaml")
host = config.database.host
print(f"Host: {host}")
# Host: prod-db.example.com
use holoconf::Config;
use std::env;

// Without environment variables
let config = Config::load("config.yaml")?;
let host: String = config.get("database.host")?;
println!("Host: {}", host);
// Host: localhost

// With environment variables
env::set_var("DB_HOST", "prod-db.example.com");
let config = Config::load("config.yaml")?;
let host: String = config.get("database.host")?;
println!("Host: {}", host);
// Host: prod-db.example.com
# Without environment variable
$ holoconf get config.yaml database.host
localhost

# With environment variable
$ DB_HOST=prod-db.example.com holoconf get config.yaml database.host
prod-db.example.com

Perfect! Now your configuration works in development (using defaults) and production (using environment variables).

Self-References: Avoiding Duplication

As your configuration grows, you'll find yourself repeating values. Self-references let you define a value once and reuse it everywhere.

Let's say you have shared defaults:

defaults:
  timeout: 30
  host: localhost

database:
  host: ${defaults.host}
  timeout: ${defaults.timeout}
  port: 5432

api:
  host: ${defaults.host}
  timeout: ${defaults.timeout}
  port: 8000

Now when you access these values, they resolve to the shared defaults:

from holoconf import Config

config = Config.load("config.yaml")

db_host = config.database.host
api_host = config.api.host
print(f"Database: {db_host}, API: {api_host}")
# Database: localhost, API: localhost

# Both reference the same value
db_timeout = config.database.timeout
api_timeout = config.api.timeout
print(f"Timeouts: {db_timeout}, {api_timeout}")
# Timeouts: 30, 30
use holoconf::Config;

let config = Config::load("config.yaml")?;

let db_host: String = config.get("database.host")?;
let api_host: String = config.get("api.host")?;
println!("Database: {}, API: {}", db_host, api_host);
// Database: localhost, API: localhost
$ holoconf get config.yaml database.host
localhost

$ holoconf get config.yaml api.host
localhost

Relative References

But what if you want to reference a value that's nearby in the configuration tree? Absolute paths like ${defaults.host} work, but they're verbose. Use relative paths instead:

database:
  host: localhost
  port: 5432
  # Reference sibling 'host' using dot prefix
  connection_string: "postgres://${.host}:${.port}/mydb"

services:
  database:
    host: prod-db.example.com

  api:
    # Reference parent's sibling using double-dot
    db_host: ${..database.host}

Let's see what these resolve to:

from holoconf import Config

config = Config.load("config.yaml")

# Sibling reference
conn_str = config.database.connection_string
print(f"Connection: {conn_str}")
# Connection: postgres://localhost:5432/mydb

# Parent's sibling reference
api_db = config.services.api.db_host
print(f"API uses DB: {api_db}")
# API uses DB: prod-db.example.com
use holoconf::Config;

let config = Config::load("config.yaml")?;

let conn_str: String = config.get("database.connection_string")?;
println!("Connection: {}", conn_str);
// Connection: postgres://localhost:5432/mydb
$ holoconf get config.yaml database.connection_string
postgres://localhost:5432/mydb

$ holoconf get config.yaml services.api.db_host
prod-db.example.com

When to Use Relative vs Absolute References

  • Use ${.sibling} for values in the same section (like building connection strings)
  • Use ${..parent.sibling} for nearby values (like services referencing shared settings)
  • Use ${absolute.path} for shared defaults used across the entire config

Preventing Circular References

What happens if you create a circular reference?

a:
  value: ${b.value}

b:
  value: ${a.value}

HoloConf detects this and gives you a clear error:

from holoconf import Config, CircularReferenceError

config = Config.load("config.yaml")

try:
    value = config.a.value
except CircularReferenceError as e:
    print(f"Error: {e}")
    # Error: Circular reference detected: a.value -> b.value -> a.value
use holoconf::{Config, Error};

let config = Config::load("config.yaml")?;
match config.get::<String>("a.value") {
    Err(Error::CircularReference { path, .. }) => {
        println!("Circular reference at: {}", path);
    }
    _ => {}
}
$ holoconf get config.yaml a.value
Error: Circular reference detected: a.value -> b.value -> a.value

The error shows you the exact reference chain, making it easy to fix.

File Includes: Splitting Large Configurations

Sometimes configuration is too large for a single value. Maybe you have a multi-line certificate, a JSON blob, or a YAML snippet. The file resolver lets you include content from external files.

Let's say you have a private key in a separate file:

config/
├── app.yaml
└── secrets/
    └── private-key.pem
# app.yaml
ssl:
  certificate: ${file:secrets/cert.pem}
  private_key: ${file:secrets/private-key.pem}

When you access these values, the file content is included:

from holoconf import Config

config = Config.load("config/app.yaml")

# Returns the file content as a string
private_key = config.ssl.private_key
print(f"Key length: {len(private_key)} bytes")
# Key length: 1704 bytes
use holoconf::Config;

let config = Config::load("config/app.yaml")?;
let private_key: String = config.get("ssl.private_key")?;
println!("Key length: {} bytes", private_key.len());
$ holoconf get config/app.yaml ssl.private_key
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC...
...

Including Structured Data

But what if the file contains YAML or JSON? HoloConf automatically parses it:

# users.yaml (separate file)
- name: alice
  role: admin
- name: bob
  role: user
# app.yaml
users: ${file:users.yaml}
admin_name: ${file:users.yaml[0].name}
from holoconf import Config

config = Config.load("app.yaml")

# Returns parsed YAML as a list
users = config.users
print(f"Users: {users}")
# Users: [{'name': 'alice', 'role': 'admin'}, {'name': 'bob', 'role': 'user'}]

# Can reference into the structure
admin = config.admin_name
print(f"Admin: {admin}")
# Admin: alice
use holoconf::Config;

let config = Config::load("app.yaml")?;
let admin: String = config.get("admin_name")?;
println!("Admin: {}", admin);
// Admin: alice
$ holoconf get app.yaml admin_name
alice

Relative File Paths

File paths are relative to the configuration file, not your current directory:

project/
├── config/
│   ├── app.yaml
│   └── database.yaml
└── secrets/
    └── db-password.txt
# config/app.yaml
database: ${file:database.yaml}
password: ${file:../secrets/db-password.txt}

The paths database.yaml and ../secrets/db-password.txt are resolved relative to config/app.yaml, not where you run your program.

RFC 8089 File URI Syntax

HoloConf supports RFC 8089 file: URI syntax for explicit absolute paths. This is useful when you want to be explicit about absolute paths:

# RFC 8089 file: URIs (all equivalent for absolute paths)
system_config: ${file:///etc/myapp/config.yaml}
explicit_local: ${file://localhost/etc/myapp/config.yaml}
minimal_form: ${file:/etc/myapp/config.yaml}

All three forms reference the same absolute path /etc/myapp/config.yaml. The resolver normalizes them to the appropriate path format.

Remote file URIs are rejected:

# This will error - remote file URIs not supported
remote_file: ${file://server.example.com/path/to/file}

HoloConf only supports local file access for security reasons. Remote file URIs (with a non-localhost hostname) are rejected with a clear error message.

When to Use RFC 8089 Syntax

Use plain paths for most cases: ${file:./config.yaml} or ${file:/etc/app/config.yaml}.

Use RFC 8089 syntax when: - Interoperating with systems that use file: URIs - You want to be explicit about the path being absolute - Your configuration documents the URI format for clarity

Handling Missing Files

What if the file doesn't exist?

from holoconf import Config, ResolverError

# config.yaml contains: cert: ${file:missing.pem}
config = Config.load("config.yaml")

try:
    cert = config.cert
except ResolverError as e:
    print(f"Error: {e}")
    # Error: File not found: missing.pem
let config = Config::load("config.yaml")?;
match config.get::<String>("cert") {
    Err(Error::ResolverError { message, .. }) => {
        println!("Error: {}", message);
    }
    _ => {}
}
$ holoconf get config.yaml cert
Error: File not found: missing.pem

You can provide a default instead:

cert: ${file:cert.pem,default=selfsigned-cert-content}

HTTP/HTTPS: Remote Configuration

For centralized configuration management, you can fetch values from HTTP endpoints. This is useful for:

  • Fetching config from a configuration server
  • Loading shared defaults from a central location
  • Integrating with REST APIs

HoloConf provides separate http and https resolvers that auto-prepend the appropriate protocol, making your configs cleaner:

# Clean syntax - resolver auto-prepends https://
feature_flags: ${https:config.example.com/flags.json}

# Also works with http
api_config: ${http:api.internal/config.json}

But wait - HTTP fetching is disabled by default for security. You need to explicitly enable it:

from holoconf import Config

# This will error
config = Config.load("config.yaml")
flags = config.feature_flags
# Error: HTTP resolvers are disabled. Pass allow_http=True to enable.

Let's enable HTTP fetching:

from holoconf import Config

config = Config.load("config.yaml", allow_http=True)

# Now it works - fetches from the URL
flags = config.feature_flags
print(f"Flags: {flags}")
# Flags: {'feature_a': True, 'feature_b': False}
use holoconf::{Config, ConfigOptions};

let options = ConfigOptions::default().allow_http(true);
let config = Config::load_with_options("config.yaml", options)?;

let flags: serde_json::Value = config.get("feature_flags")?;
println!("Flags: {:?}", flags);
# Enable HTTP with --allow-http flag
$ holoconf get config.yaml feature_flags --allow-http
feature_a: true
feature_b: false

Security: HTTP Disabled by Default

HTTP fetching is disabled by default because:

  • It can leak sensitive configuration paths to network logs
  • It introduces network dependencies during config loading
  • It may expose your infrastructure to SSRF attacks

Only enable it when you specifically need remote configuration.

Handling Network Errors

What happens if the HTTP request fails?

data: ${https:api.example.com/config}
from holoconf import Config, ResolverError

config = Config.load("config.yaml", allow_http=True)

try:
    data = config.data
except ResolverError as e:
    print(f"Error: {e}")
    # Error: HTTP request failed: connection timeout
match config.get::<String>("data") {
    Err(Error::ResolverError { message, .. }) => {
        println!("HTTP error: {}", message);
    }
    _ => {}
}
$ holoconf get config.yaml data --allow-http
Error: HTTP request failed: connection timeout

You can provide a fallback:

data: ${https:api.example.com/config,default={}}

Now if the HTTP request fails, it uses the empty object instead of erroring.

Custom Request Timeouts

The default timeout is 30 seconds. For slower endpoints, increase it:

from holoconf import Config

# Set HTTP timeout to 60 seconds
config = Config.load(
    "config.yaml",
    allow_http=True,
    http_timeout=60
)

data = config.data  # Waits up to 60 seconds
use holoconf::{Config, ConfigOptions};
use std::time::Duration;

let options = ConfigOptions::default()
    .allow_http(true)
    .http_timeout(Duration::from_secs(60));

let config = Config::load_with_options("config.yaml", options)?;
# Set timeout to 60 seconds
$ holoconf get config.yaml data --allow-http --http-timeout 60

Working with Internal Certificate Authorities

Many organizations use internal certificate authorities for HTTPS services. Let's see how to configure HoloConf to trust these.

First, let's try fetching from an internal service:

config:
  data: ${https:internal.corp.com/config.json}
config = Config.load("config.yaml", allow_http=True)
data = config.data
# Error: SSL certificate verification failed

The error occurs because your organization's CA isn't in the default trust store. We can fix this by providing your CA certificate:

from holoconf import Config

config = Config.load(
    "config.yaml",
    allow_http=True,
    http_ca_bundle="/etc/ssl/certs/internal-ca.pem"
)

data = config.data  # Now it works!
use holoconf::{Config, ConfigOptions};

let options = ConfigOptions::default()
    .allow_http(true)
    .http_ca_bundle("/etc/ssl/certs/internal-ca.pem");

let config = Config::load_with_options("config.yaml", options)?;
$ holoconf get config.yaml data --allow-http --http-ca-bundle /etc/ssl/certs/internal-ca.pem

This replaces the default CA bundle with your custom one. But what if you need to trust BOTH public CAs (for external APIs) AND your internal CA? Use http_extra_ca_bundle instead:

from holoconf import Config

config = Config.load(
    "config.yaml",
    allow_http=True,
    http_extra_ca_bundle="/etc/ssl/certs/internal-ca.pem"  # Added to defaults
)

data = config.data  # Now trusts both public and internal CAs
let options = ConfigOptions::default()
    .allow_http(true)
    .http_extra_ca_bundle("/etc/ssl/certs/internal-ca.pem");

let config = Config::load_with_options("config.yaml", options)?;
$ holoconf get config.yaml data --allow-http --http-extra-ca-bundle /etc/ssl/certs/internal-ca.pem

This adds your CA to the existing trust store, so you can fetch from both internal and external HTTPS endpoints.

Using Certificate Variables

Instead of storing certificates as files, you can load them from environment variables or other resolvers. This is useful for:

  • Containerized environments where secrets are injected as environment variables
  • Secret management systems that provide certificates dynamically
  • CI/CD pipelines where certificates are stored in secure vaults

PEM Certificates from Environment Variables

Let's load client certificates for mTLS from environment variables:

# config.yaml
secure_api:
  data: ${https:api.corp.com/config,client_cert=${env:CLIENT_CERT_PEM},client_key=${env:CLIENT_KEY_PEM}}
import os
from holoconf import Config

# Set certificates in environment (in practice, these come from your secret management system)
os.environ["CLIENT_CERT_PEM"] = """-----BEGIN CERTIFICATE-----
MIICijCCAXICCQC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789
...
-----END CERTIFICATE-----"""

os.environ["CLIENT_KEY_PEM"] = """-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAK1234567890
...
-----END PRIVATE KEY-----"""

config = Config.load("config.yaml", allow_http=True)
data = config.secure_api.data  # Uses certificates from environment
use holoconf::Config;
use std::env;

// Set certificates in environment
env::set_var("CLIENT_CERT_PEM", "-----BEGIN CERTIFICATE-----\n...");
env::set_var("CLIENT_KEY_PEM", "-----BEGIN PRIVATE KEY-----\n...");

let config = Config::load("config.yaml")?;
let data = config.get("secure_api.data")?;

CA Bundle from File Resolver

You can also load CA bundles using the file resolver with parse=text:

# config.yaml
internal_api:
  data: ${https:internal.corp.com/config,ca_bundle=${file:./certs/ca-bundle.pem,parse=text}}

This reads the CA bundle file and passes its PEM content directly to the HTTPS resolver.

P12/PFX Binary Certificates (Python)

For P12/PFX certificates (which contain both certificate and key), use parse=binary:

# config.yaml
secure_data:
  value: ${https:secure.example.com/data,client_cert=${file:./certs/identity.p12,parse=binary},key_password=${env:P12_PASSWORD}}
import os
from holoconf import Config

os.environ["P12_PASSWORD"] = "secret"

config = Config.load("config.yaml", allow_http=True)
value = config.secure_data.value  # Uses P12 certificate

Auto-Detection: HoloConf automatically detects whether you're providing: - A file path (string without -----BEGIN marker) - PEM content (string containing -----BEGIN CERTIFICATE----- or -----BEGIN PRIVATE KEY-----) - P12 binary (bytes type in Python, detected by .p12/.pfx extension for paths)

This means you can mix and match:

# Mixed mode: cert from environment, key from file path
api_config: ${https:api.example.com/config,client_cert=${env:CERT_PEM},client_key=/etc/ssl/private/key.pem}

Authentication Headers

Some configuration endpoints require authentication. Add custom headers:

from holoconf import Config

config = Config.load(
    "config.yaml",
    allow_http=True,
    http_headers={
        "Authorization": "Bearer YOUR_TOKEN_HERE",
        "X-Custom-Header": "value"
    }
)

data = config.data  # Includes auth headers in request
use holoconf::{Config, ConfigOptions};
use std::collections::HashMap;

let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer YOUR_TOKEN_HERE".to_string());

let options = ConfigOptions::default()
    .allow_http(true)
    .http_headers(headers);

let config = Config::load_with_options("config.yaml", options)?;
# CLI doesn't support custom headers - use environment variables in the URL instead
$ holoconf get config.yaml data --allow-http

Caching HTTP Responses

HTTP values are fetched every time you access them. For frequently accessed values:

# Fetch once and cache
flags = config.feature_flags

# Use cached value
if flags['feature_a']:
    # ...

This avoids repeated network requests.

Quick Reference

Here's a summary of all core resolvers:

Resolver Syntax Description Example
env ${env:VAR} Environment variable ${env:DB_HOST,default=localhost}
Self-reference ${path} Absolute reference ${defaults.timeout}
Self-reference ${.key} Sibling reference ${.host}
Self-reference ${..parent.key} Parent's sibling ${..shared.timeout}
file ${file:path} File content or RFC 8089 URI ${file:secrets/key.pem}
http ${http:url} HTTP GET (auto-prepends http://) ${http:api.example.com/config}
https ${https:url} HTTPS GET (auto-prepends https://) ${https:api.example.com/config}

All resolvers support:

  • default=value - Fallback if resolver fails
  • sensitive=true - Mark value for redaction

What You've Learned

You now understand:

  • Using ${env:VAR} to read environment variables
  • Providing defaults with default=value
  • Referencing other config values with absolute and relative paths
  • Preventing circular references
  • Including file content with ${file:path}
  • Fetching remote config with ${http:url} and ${https:url}
  • Configuring HTTP timeouts, CA bundles, and auth headers
  • Security implications of HTTP resolvers

Next Steps

See Also