Configuration management service for the Gas ecosystem. Supports loading from multiple providers (environment variables, JSON files, .env files) and binding to Go structs.
- Early initialization:
New()returns*Configdirectly — create and load before the app starts - Multiple providers: Environment variables, JSON files,
.envfiles, and custom providers - Hierarchical config: Nested access via dot notation (e.g.,
database.host) - Struct binding: Type-safe binding with validation support
- Sensible defaults: Environment provider is registered automatically
go get github.com/gasmod/gas-configpackage main
import (
config "github.com/gasmod/gas-config"
"github.com/gasmod/gas-config/providers"
)
func main() {
// Create and load config before anything else
cfg := config.New(
config.WithProvider(
providers.NewJSONProvider(
providers.WithJSONFilePath("config.json"),
),
),
config.WithProvider(providers.NewDotEnvProvider()),
)
if err := cfg.Load(); err != nil {
panic(err)
}
// Bind to a struct
var appCfg AppConfig
if err := cfg.Bind(&appCfg); err != nil {
panic(err)
}
}Config is loaded before app.Run() and registered as a singleton instance so
other services can receive it as gas.ConfigProvider via constructor injection.
package main
import (
"github.com/gasmod/gas"
config "github.com/gasmod/gas-config"
"github.com/gasmod/gas-config/providers"
)
func main() {
cfg := config.New(
config.WithProvider(providers.NewDotEnvProvider()),
config.WithProvider(
providers.NewJSONProvider(
providers.WithJSONFilePath("config.json"),
),
),
)
if err := cfg.Load(); err != nil {
panic(err)
}
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
// other services ...
)
app.Run()
}
type AppConfig struct {
Database struct {
Host string
Port int
}
Server struct {
Host string
Port int
}
}An EnvProvider is automatically added if none is provided. Environment variables are lowercased and kept as flat snake_case keys by default:
export DATABASE_HOST=localhost # → database_host
export DATABASE_PORT=5432 # → database_portTo create nested maps, use __ (double underscore) as the separator:
export DATABASE__HOST=localhost # → database.host (nested)
export DATABASE__PORT=5432 # → database.port (nested)cfg := config.New() // EnvProvider included by defaultOptions:
config.New(
config.WithProvider(
providers.NewEnvProvider(
providers.WithEnvPrefix("APP"),
providers.WithEnvSeparator("__"),
),
),
)config.New(
config.WithProvider(
providers.NewJSONProvider(
providers.WithJSONFilePath("config.json"),
providers.WithJSONFileFS(embeddedFS), // optional: custom fs.FS
),
),
).env variables follow the same key conventions as env vars: lowercased, flat snake_case by default; use __ for nesting.
config.New(
config.WithProvider(
providers.NewDotEnvProvider(
providers.WithDotEnvFilePath(".env"),
providers.WithDotEnvFileNotFoundPanic(false),
providers.WithDotEnvFileAppendToOSEnv(true),
),
),
)Loads secrets from AWS Secrets Manager.
Secrets are registered explicitly and fetched eagerly at Load() time.
import "github.com/gasmod/gas-config/providers/secretsmanager"
cfg := config.New(
config.WithProvider(secretsmanager.NewProvider(
// JSON-object secret, deep-merged at the root.
secretsmanager.WithSecret("myapp/config"),
// Raw-string secret, placed at a dot-notation key.
secretsmanager.WithSecretAtKey("myapp/db-pass", "database.password"),
secretsmanager.WithRegion("eu-west-1"),
// Optional: static credentials (default AWS chain otherwise).
secretsmanager.WithStaticCredentials(accessKeyID, secretAccessKey),
// Optional: custom endpoint for LocalStack.
secretsmanager.WithEndpoint("http://localhost:4566"),
// Optional: timeout for Load() without a caller context (default 10s).
secretsmanager.WithTimeout(10*time.Second),
)),
)WithSecret requires the secret value to be a JSON object and merges it like
a JSON file. WithSecretAtKey nests a JSON object at the key, or places the
raw string there. Later registrations win on key conflicts. Missing or
undecodable secrets fail Load().
Implement the Provider interface:
type Provider interface {
Name() string
Load() (map[string]any, error)
}Providers that call remote services can additionally implement
ContextProvider (LoadContext(ctx context.Context) (map[string]any, error));
LoadWithContext passes its context to providers that support it.
Later providers override earlier ones. The auto-registered EnvProvider is prepended, so explicit providers take precedence:
cfg := config.New(
config.WithProvider(providers.NewJSONProvider(...)), // base config
config.WithProvider(providers.NewDotEnvProvider()), // overrides JSON
// EnvProvider is prepended automatically (lowest priority)
)// Get a single value
host := cfg.Get("database.host")
// Check if a value exists
val, exists := cfg.Find("database.host")
// Set defaults (won't override loaded values)
cfg.SetDefault("database.port", 5432)
// Set a value (overrides loaded values)
cfg.Set("database.host", "127.0.0.1")
// Get all values
allValues := cfg.Values()Bind() maps configuration into structs using reflection. Field matching uses json tags first, then case-insensitive field names:
type DBConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Password string `json:"password" validate:"required"`
}
var dbCfg DBConfig
if err := cfg.Bind(&dbCfg); err != nil {
log.Fatal(err)
}
// Disable validation
cfg.Bind(&dbCfg, config.WithValidate(false))Supported types: all int/uint/float variants, bool, string, time.Duration, slices (including comma-separated strings), maps, nested structs, and embedded structs.
Pass config.WithValidator to New to use a caller-owned *validator.Validate (e.g. with custom tags registered). If omitted, a new validator.New() instance is used internally.
v := validator.New()
v.RegisterValidation("mytag", myValidatorFunc)
cfg := config.New(config.WithValidator(v))Extensions provide pre/post-load hooks:
type Extension interface {
Name() string
PreLoad(ctx context.Context, cfg *Config) error
PostLoad(ctx context.Context, cfg *Config) error
}cfg := config.New(config.WithExtension(myExtension))The gas-env extension (extensions/gas-env) manages application environment detection and validation. It resolves the current environment from config providers, the GAS_ENV OS variable, or a default, and makes it available throughout the app.
import (
config "github.com/gasmod/gas-config"
gasenv "github.com/gasmod/gas-config/extensions/gas-env"
)
envExt := gasenv.NewExtension()
cfg := config.New(
config.WithProvider(providers.NewDotEnvProvider()),
config.WithExtension(envExt),
)
if err := cfg.Load(); err != nil {
panic(err)
}
fmt.Println(envExt.Current()) // "development"
fmt.Println(envExt.IsProduction()) // false
fmt.Println(envExt.IsDevelopmentLike()) // trueEnvironments: Development, Testing, Staging, Production
Resolution priority:
- Config providers (JSON,
.env, etc.) - OS environment variable (
GAS_ENVby default) - Default (
Development)
Options:
gasenv.NewExtension(
gasenv.WithEnvVarName("APP_ENV"),
gasenv.WithDefault(gasenv.Production),
gasenv.WithConfigKey("AppEnv"),
gasenv.WithAllowedEnvs(gasenv.Production, gasenv.Staging),
)Embedding in config structs:
type AppConfig struct {
gasenv.WithGasEnv // adds GasEnv field, auto-populated by Bind()
Database struct {
Host string
Port int
}
}
var appCfg AppConfig
cfg.Bind(&appCfg)
fmt.Println(appCfg.GasEnv.IsProduction()) // falseThe configtest package provides a mock that structurally satisfies gas.ConfigProvider:
import "github.com/gasmod/gas-config/configtest"
mock := &configtest.MockConfig{}
mock.GetFn = func(key string) any {
if key == "database.host" {
return "localhost"
}
return nil
}
// assert calls:
if mock.CallCount("Get") != 1 {
t.Error("expected one Get call")
}For tests that need real Get/Find/Bind semantics seeded with known values, use NewMockConfigWithValues, which delegates to a real *config.Config under the hood and avoids leaking real environment variables:
mock, err := configtest.NewMockConfigWithValues(map[string]any{
"database.host": "localhost",
"database.port": 5432,
})Individual Fn fields can still be overridden afterwards, and Calls/Reset/CallCount work as usual.
See examples/ for complete examples: