Config
What it is for
Section titled “What it is for”gas/config reads configuration from one or more sources, merges it into one
tree of lowercased keys, and binds that tree into Go structs with validation.
*config.Config satisfies gas.ConfigProvider, the interface core and every
module read their settings through. Core binds its own server settings from
it, and each module binds its section (database.*, email.*, jwt.*, …)
in its Init.
Config is not a service. It has no Init or Close; you build and load it
before the app, so a bad configuration fails before any service starts, then
register the loaded value as an instance.
Install
Section titled “Install”go get github.com/gasmod/gas/configWiring
Section titled “Wiring”// Config is built and loaded before the app, so a bad configuration// fails before any service starts. Later providers override earlier ones.cfg := config.New( config.WithProvider(providers.NewJSONProvider( providers.WithJSONFilePath("config.json"), )), config.WithProvider(providers.NewEnvProvider()), // overrides the file config.WithExtension(gasenv.NewExtension()),)if err := cfg.Load(); err != nil { log.Fatal(err)}
app := gas.NewApp( // Registered as an instance under the interface every module binds // its own settings through. gas.WithServiceInstance[gas.ConfigProvider](cfg), gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()), gas.WithSingletonService[*Notes](NewNotes),)config.New takes providers and extensions and returns the config without
reading anything. Load reads every source. When New is given no provider
at all, it adds providers.NewEnvProvider() by itself; as soon as you pass one
provider explicitly, add the environment provider yourself if you still want
environment variables.
Environment variable names
Section titled “Environment variable names”This is the rule for every config key on this site. To set a key from the
environment, uppercase it and replace each . with a double underscore:
| Config key | Environment variable |
|---|---|
server.port |
SERVER__PORT |
database.dsn |
DATABASE__DSN |
email.fromemail |
EMAIL__FROMEMAIL |
jwt.signingkey |
JWT__SIGNINGKEY |
The environment provider lowercases each variable name and splits it on __
into levels, so names are matched case-insensitively and a single underscore
stays part of the key: REQUEST_TIMEOUT is the key request_timeout. Values
arrive as strings; binding converts them to the field’s
type.
WithEnvPrefix keeps only the variables that start with the prefix (compared
case-insensitively) and cuts it off literally, so include the trailing
underscore. With the prefix APP_, the variable for database.dsn is
APP_DATABASE__DSN:
// Only variables starting with APP_ are read, and the prefix is cut off// literally, so APP_DATABASE__DSN becomes database.dsn.func prefixedEnv() *providers.EnvProvider { return providers.NewEnvProvider(providers.WithEnvPrefix("APP_"))}A fixed list of system variables, such as PATH and HOME, is never read.
See The environment provider.
Providers
Section titled “Providers”| Provider | Source |
|---|---|
providers.NewEnvProvider() |
The process environment |
providers.NewDotEnvProvider() |
A .env file |
providers.NewJSONProvider() |
A JSON file, from disk or an fs.FS |
secretsmanager.NewProvider() |
AWS Secrets Manager, fetched at load |
Load reads the providers in registration order and merges them, so a later
provider overrides the keys it defines and leaves the rest alone. See
Load order for the full sequence.
.env and JSON files
Section titled “.env and JSON files”// Reads ./.env with the same naming rule as the environment. A missing file// is an error unless WithDotEnvFileNotFoundPanic(false) is passed.func localDotEnv() *providers.DotEnvProvider { return providers.NewDotEnvProvider( providers.WithDotEnvFileNotFoundPanic(false), )}NewDotEnvProvider reads .env from the working directory unless
WithDotEnvFilePath says otherwise, and turns each KEY=VALUE line into a key
with the same rule as the environment (__ splits levels; change it with
WithDotEnvSeparator). A missing file is an error by default.
NewJSONProvider reads one JSON object from the path set with
WithJSONFilePath. There is no default path: without one, Load fails with
providers.ErrJSONFilePathNotSet. JSON numbers arrive as float64, and a
number has no unit, so a duration in a JSON file is written as a string such
as "30s" (see durations).
To ship a file inside the binary, read it from an embed.FS:
//go:embed defaults.jsonvar defaultsFS embed.FS
// Reads defaults.json from the binary instead of the working directory.func embeddedDefaults() *providers.JSONProvider { return providers.NewJSONProvider( providers.WithJSONFileFS(defaultsFS), providers.WithJSONFilePath("defaults.json"), )}See File providers for the path checks, the size limit and
the .env syntax.
AWS Secrets Manager
Section titled “AWS Secrets Manager”// Fetches secrets when the config loads. app/prod must hold a JSON object,// merged at the root; app/db-password is placed at database.password.func secrets() *secretsmanager.Provider { return secretsmanager.NewProvider( secretsmanager.WithRegion("eu-west-1"), secretsmanager.WithSecret("app/prod"), secretsmanager.WithSecretAtKey("app/db-password", "database.password"), )}The provider lives in github.com/gasmod/gas/config/providers/secretsmanager.
Secrets are fetched on every load, in registration order, and merged with
later ones winning. A secret registered with WithSecret must hold a JSON
object, which is merged at the root. WithSecretAtKey places a secret at a
dot-separated key: a JSON object is nested there, and any other value is
stored there as a string.
See Secrets Manager client and timeouts for the AWS client options.
Reading and setting values
Section titled “Reading and setting values”Get("database.host") walks the dot-separated path, case-insensitively, and
returns nil when the key is missing; Find also reports whether it exists.
Get, Find and Values return deep copies, so changing a returned map does
not change the config.
Set(key, value) writes a value, overriding what is there. SetDefault(key, value) writes only when the key is not set yet. SetDefaults(values) does the
same for a whole map[string]any or struct; it returns
config.ErrNilValues for nil. Map keys passed to SetDefaults are taken as
one level each and not split on ., so pass nested maps.
Binding to structs
Section titled “Binding to structs”// NotesConfig is bound from the notes.* keys. Fields match their lowercased// name: notes.pagesize, notes.timeout, notes.tags.type NotesConfig struct { gas.WithGasEnv
Notes struct { PageSize int `validate:"min=1,max=500"` Timeout time.Duration Tags []string }}
// Notes binds its settings in Init, the way every Gas module does.type Notes struct { cfgProvider gas.ConfigProvider cfg NotesConfig}
func NewNotes(cfgProvider gas.ConfigProvider) (*Notes, error) { return &Notes{cfgProvider: cfgProvider}, nil}
func (n *Notes) Name() string { return "notes" }
func (n *Notes) Init(context.Context) error { // Defaults first: Bind sets only the fields whose keys are present. n.cfg.Notes.PageSize = 50 n.cfg.Notes.Timeout = 5 * time.Second
if err := n.cfgProvider.Bind(&n.cfg); err != nil { return fmt.Errorf("notes: %w", err) } if n.cfg.GasEnv.IsProduction() { n.cfg.Notes.Timeout = 2 * time.Second } return nil}
func (n *Notes) Close(context.Context) error { return nil }Bind(&dest) fills a struct from the whole tree. Each field matches its
json tag, or else its lowercased field name; when both are present in the
config, the tag wins.
Bind sets only the fields whose keys are present and leaves every other
field as it was, so the usual pattern is to fill in defaults and bind over
them, as every Gas module does with its DefaultConfig().
After binding, Bind validates the struct with
go-playground/validator against
its validate tags. Pass config.WithValidate(false) to one Bind call to
skip it, or config.WithValidator(v) to config.New to use your own
validator instance with custom rules registered. A binding failure is returned
as bind config: ... and a validation failure as validate config: ....
See Binding rules for field matching and the supported types.
Environments
Section titled “Environments”The gasenv extension (github.com/gasmod/gas/config/extensions/gasenv)
resolves which environment the app runs in and stores it at the config key
gasenv. The allowed values are development, testing, staging and
production, compared case-sensitively. A value set by a provider comes
first, then the GAS_ENV variable, then development.
Embed gas.WithGasEnv in a config struct and Bind fills its GasEnv field,
which has IsDevelopment, IsTesting, IsStaging, IsProduction,
IsDevelopmentLike (development or testing) and IsProductionLike (staging
or production). Every Gas module’s config embeds it except Storage’s: the
s3 and dir configs do not.
To ask before the app exists, keep the extension; it has the same helpers plus
Current():
// Keep the extension to ask for the environment before the app exists.func loadWithEnv() (*config.Config, *gasenv.Extension, error) { envExt := gasenv.NewExtension( gasenv.WithAllowedEnvs(gasenv.Development, gasenv.Production), ) cfg := config.New(config.WithExtension(envExt)) if err := cfg.Load(); err != nil { return nil, nil, err } if envExt.IsProduction() { cfg.Set("notes.timeout", "2s") } return cfg, envExt, nil}See Environment resolution for the exact rules and the extension’s options.
Extensions
Section titled “Extensions”An extension implements config.Extension: Name(), PreLoad(ctx, cfg) and
PostLoad(ctx, cfg), called around the providers as described in
Load order.
// stampExtension records when the configuration was loaded. PreLoad runs// before any provider, PostLoad after all of them.type stampExtension struct{}
func (stampExtension) Name() string { return "stamp" }
func (stampExtension) PreLoad(_ context.Context, cfg *config.Config) error { cfg.SetDefault("app.region", "eu-west-1") return nil}
func (stampExtension) PostLoad(_ context.Context, cfg *config.Config) error { cfg.Set("app.loadedat", time.Now().UTC().Format(time.RFC3339)) return nil}A failing hook stops Load with an error wrapping
config.ErrExtensionPreLoadHookFailed or
config.ErrExtensionPostLoadHookFailed and the extension’s name.
Writing a provider
Section titled “Writing a provider”A provider implements providers.Provider: Name() string and
Load() (map[string]any, error), returning a nested map. If it also implements
providers.ContextProvider, LoadContext(ctx) (map[string]any, error),
LoadWithContext and LoadProviderContext call that with their context
instead, which matters for anything that does network calls.
Testing
Section titled “Testing”configtest.MockConfig satisfies gas.ConfigProvider. Each method records
the call in Calls and delegates to its Fn field (GetFn, BindFn, …)
when set; with no Fn, it returns the zero value, so Bind leaves the struct
untouched and returns nil. CallCount(method) and Reset() help with
assertions.
When a test needs real Get, Find and Bind behavior over known values,
configtest.NewMockConfigWithValues builds a mock whose Fn fields delegate to
a real *config.Config seeded with the values you pass. It does not read the
process environment, so the machine running the test cannot leak into it.
// NewMockConfigWithValues delegates to a real config seeded with the given// values, without reading the process environment. Seed nested maps, not// dotted keys.func TestNotesBindsPageSize(t *testing.T) { cfg, err := configtest.NewMockConfigWithValues(map[string]any{ "notes": map[string]any{"pagesize": 20, "timeout": "1s"}, }) if err != nil { t.Fatal(err) }
notes, err := NewNotes(cfg) if err != nil { t.Fatal(err) } if err := notes.Init(context.Background()); err != nil { t.Fatal(err) }
if notes.cfg.Notes.PageSize != 20 || notes.cfg.Notes.Timeout != time.Second { t.Fatalf("got %+v", notes.cfg.Notes) } if cfg.CallCount("Bind") != 1 { t.Fatalf("Bind called %d times, want 1", cfg.CallCount("Bind")) }}Configuration
Section titled “Configuration”gas/config reads one key itself, through the gasenv extension:
| Key | Type | Default | Description |
|---|---|---|---|
gasenv |
string | GAS_ENV when it holds an allowed value, else development |
The resolved environment, set by the extension during Load. See Environments. |
Core binds its server settings (server.host, server.port, the server
timeouts) from the same config; see Deployment.
Errors
Section titled “Errors”| Error | Returned when |
|---|---|
config.ErrProviderLoadFailed |
A provider’s Load failed during Load or LoadProvider. Wraps the provider’s own error. |
config.ErrExtensionPreLoadHookFailed |
An extension’s PreLoad failed. |
config.ErrExtensionPostLoadHookFailed |
An extension’s PostLoad failed. |
config.ErrNilValues |
SetDefaults(nil). |
providers.ErrJSONFilePathNotSet |
A JSON provider has no path. |
providers.ErrJSONFileReadFailed |
The JSON file could not be read, including a path outside the working directory, a symlink, or a file over 1 MiB. |
providers.ErrJSONDecodeFailed |
The file is not valid JSON, or holds an array, string, number or boolean. A file holding null loads with no error and no values (KI-047 in Known issues). |
providers.ErrDotEnvFilePathNotSet |
A .env provider’s path was set to "". |
providers.ErrDotEnvFileReadFailed |
The .env file could not be read, or is missing while missing files are errors. |
providers.ErrDotEnvParseFailed |
The .env file could not be parsed, for example a quoted value that is never closed. |
providers.ErrSetEnv |
Copying an entry into the process environment failed. |
secretsmanager.ErrNoSecretsConfigured |
No secret was registered. |
secretsmanager.ErrClientInitFailed |
The AWS configuration could not be loaded. |
secretsmanager.ErrSecretFetchFailed |
GetSecretValue failed. The error names the secret. |
secretsmanager.ErrSecretDecodeFailed |
A secret registered with WithSecret is not a JSON object. |
The provider errors reach Load’s caller wrapped in
config.ErrProviderLoadFailed, and errors.Is finds both. The environment
provider never fails.
Behavior details
Section titled “Behavior details”The sections above cover everyday use. This section is the full contract, for when an exact edge case matters.
Load order
Section titled “Load order”Load runs in this order:
- Every extension’s
PreLoadhook, in registration order. - Every provider’s
Load, in registration order. All of them are read before anything is merged, so if one fails,Loadreturns an error wrappingconfig.ErrProviderLoadFailedwith the provider’s name and merges none of them. - The provider results are merged in registration order. A later provider overrides the keys it defines and leaves the rest alone; nested maps are merged key by key, not replaced.
- Every extension’s
PostLoadhook, in registration order.
Keys are lowercased and trimmed at every level as they are merged, so
Database.DSN from a JSON file and DATABASE__DSN from the environment land
on the same key, database.dsn.
Load merges into the values the config already holds, so a value set with
Set before Load survives unless a provider defines the same key.
LoadProvider(p) adds one more provider after the fact: it loads p, merges
its values over the current ones, and registers it. A provider whose load
fails is not registered.
The environment provider
Section titled “The environment provider”WithEnvSeparator changes __ to another separator; an empty separator keeps
every name as one flat key. The variable name is lowercased before it is
split, so a separator containing uppercase letters never matches, for
WithEnvSeparator and WithDotEnvSeparator alike (KI-055 in
Known issues).
File providers
Section titled “File providers”In a .env file, quoted values may span lines and keep #; outside quotes,
# starts a comment. The .env provider has no prefix option.
A missing file fails Load with an error wrapping
providers.ErrDotEnvFileReadFailed. WithDotEnvFileNotFoundPanic(false) makes
a missing file yield no values instead; despite the option’s name, nothing
panics in either case.
WithDotEnvFileAppendToOSEnv(true) also copies the entries into the process
environment, except the names in the list above; it is off by default,
because those values would then be inherited by every child process.
By default the JSON and .env providers read through a guarded file system:
the path must resolve inside the working directory, its last element must not
be a symlink, and the file must be at most 1 MiB (1,048,576 bytes).
WithJSONFileFS and WithDotEnvFileFS read from any fs.FS instead, such as
an embed.FS, without those checks.
Secrets Manager client and timeouts
Section titled “Secrets Manager client and timeouts”WithRegion, WithEndpoint (for example LocalStack) and
WithStaticCredentials configure the AWS client; static credentials are used
only when both values are set, and otherwise the default AWS configuration and
credential chain apply. WithClient injects a client instead and makes those
options ignored.
The provider’s own Load() bounds the whole fetch with a 10-second timeout
(secretsmanager.DefaultTimeout), which WithTimeout changes; zero or a
negative duration means no timeout.
config.Config never calls that method: Load and LoadWithContext call the
provider’s LoadContext, which applies no timeout, so through the config the
context passed to LoadWithContext is the only bound (Load passes
context.Background(), which has none). LoadWithContext passes the caller’s
context through unchanged. This is tracked as KI-046 in
Known issues.
Registering no secret at all makes Load fail with
secretsmanager.ErrNoSecretsConfigured.
Binding rules
Section titled “Binding rules”Keys are lowercase, so a tag with uppercase letters never matches and the
field falls back to its name. Fields of an embedded struct bind at the level
of the struct that embeds them, which is how gas.WithGasEnv picks up the
top-level gasenv key.
Supported field types are every int, uint and float size, bool, string,
time.Duration, slices, fixed-size arrays, maps, nested structs, embedded
structs and pointers to any of these (allocated as needed). Strings are
converted where needed: "true" to a bool, "8080" to an int, and a
comma-separated string such as "a, b ,c" to a slice, with each element
trimmed.
A fixed-size array needs a real list, and a list with more elements than the array holds is an error rather than a silent truncation. A number too large for its field is an error.
Environment resolution
Section titled “Environment resolution”Resolution happens during Load:
- Before the providers load, the extension reads the OS variable
GAS_ENV. If it is unset or not an allowed value, it takes the fallback,development. The result is set as a default for the key. - After the providers load, an allowed value that a provider set at the key wins. Any other value there, including one a provider set that is not allowed, is replaced with the fallback.
So the order is: a provider’s value, then GAS_ENV, then development. An
invalid value is never reported as an error. Because the environment provider
reads every variable, GASENV=production is a provider value for the key and
takes precedence over GAS_ENV.
Without the extension, nothing sets the key and GasEnv stays empty unless a
provider sets gasenv.
| Option | Effect |
|---|---|
WithEnvVarName |
Reads another OS variable instead of GAS_ENV |
WithConfigKey |
Stores the result under another key, and also writes it to gasenv so gas.WithGasEnv still binds |
WithAllowedEnvs |
Narrows the allowed values |
WithDefault |
Changes the fallback |
The fallback should be one of the allowed values, which is not checked.