├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── LICENSE ├── README.md ├── aconfig.go ├── aconfig_test.go ├── aconfigdotenv ├── dotenv.go ├── dotenv_test.go ├── go.mod ├── go.sum └── testdata │ └── config.env ├── aconfighcl ├── go.mod ├── go.sum ├── hcl.go ├── hcl_test.go └── testdata │ └── config.hcl ├── aconfigtoml ├── go.mod ├── go.sum ├── testdata │ └── config.toml ├── toml.go └── toml_test.go ├── aconfigyaml ├── go.mod ├── go.sum ├── res.yaml ├── testdata │ └── config.yaml ├── yaml.go └── yaml_test.go ├── doc.go ├── example_test.go ├── go.mod ├── go.sum ├── parser.go ├── reflection.go ├── testdata ├── bad_config.json ├── complex.json ├── config.json ├── config1.json ├── config2.json ├── config3.json ├── example_config.json ├── slice-deep-structs.json ├── slice-struct-primitive-slice.json ├── toy.json └── unknown_fields.json ├── utils.go └── utils_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | commit-message: 5 | prefix: "deps:" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | day: "sunday" 10 | time: "09:00" 11 | - package-ecosystem: "github-actions" 12 | commit-message: 13 | prefix: "ci:" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | day: "sunday" 18 | time: "09:00" 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '0 0 * * 0' # run "At 00:00 on Sunday" 10 | 11 | # See https://github.com/cristalhq/.github/.github/workflows 12 | jobs: 13 | build: 14 | uses: cristalhq/.github/.github/workflows/build.yml@v0.8.1 15 | 16 | vuln: 17 | uses: cristalhq/.github/.github/workflows/vuln.yml@v0.8.1 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 cristaltech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aconfig 2 | 3 | [![build-img]][build-url] 4 | [![pkg-img]][pkg-url] 5 | [![version-img]][version-url] 6 | 7 | Simple, useful and opinionated config loader. 8 | 9 | ## Rationale 10 | 11 | There are many solutions regarding configuration loading in Go. I was looking for a simple loader that is as easy to use and understand as possible. The goal was to load config from 4 places: defaults (in the code), files, environment variables, command-line flags. This library works with all of these sources. 12 | 13 | ## Features 14 | 15 | * Simple API. 16 | * Clean and tested code. 17 | * Automatic fields mapping. 18 | * Supports different sources: 19 | * defaults in the code 20 | * files (JSON, YAML, TOML, DotENV, HCL) 21 | * environment variables 22 | * command-line flags 23 | * Dependency-free (file parsers are optional). 24 | * Ability to walk over configuration fields. 25 | 26 | ## Install 27 | 28 | Go version 1.14+ 29 | 30 | ``` 31 | go get github.com/cristalhq/aconfig 32 | ``` 33 | 34 | ## Example 35 | 36 | ```go 37 | type MyConfig struct { 38 | Port int `default:"1111" usage:"just give a number"` 39 | Auth struct { 40 | User string `required:"true"` 41 | Pass string `required:"true"` 42 | } 43 | Pass string `default:"" env:"SECRET" flag:"sec_ret"` 44 | } 45 | 46 | var cfg MyConfig 47 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 48 | // feel free to skip some steps :) 49 | // SkipDefaults: true, 50 | // SkipFiles: true, 51 | // SkipEnv: true, 52 | // SkipFlags: true, 53 | EnvPrefix: "APP", 54 | FlagPrefix: "app", 55 | Files: []string{"/var/opt/myapp/config.json", "ouch.yaml"}, 56 | FileDecoders: map[string]aconfig.FileDecoder{ 57 | // from `aconfigyaml` submodule 58 | // see submodules in repo for more formats 59 | ".yaml": aconfigyaml.New(), 60 | }, 61 | }) 62 | 63 | // IMPORTANT: define your own flags with `flagSet` 64 | flagSet := loader.Flags() 65 | 66 | if err := loader.Load(); err != nil { 67 | panic(err) 68 | } 69 | 70 | // configuration fields will be loaded from (in order): 71 | // 72 | // 1. defaults set in structure tags (see MyConfig defenition) 73 | // 2. loaded from files `file.json` if not `ouch.yaml` will be used 74 | // 3. from corresponding environment variables with the prefix `APP_` 75 | // 4. command-line flags with the prefix `app.` if they are 76 | ``` 77 | 78 | Also see examples: [examples_test.go](https://github.com/cristalhq/aconfig/blob/master/example_test.go). 79 | 80 | Integration with `spf13/cobra` [playground](https://play.golang.org/p/OsCR8qTCN0H). 81 | 82 | ## Documentation 83 | 84 | See [these docs][pkg-url]. 85 | 86 | ## License 87 | 88 | [MIT License](LICENSE). 89 | 90 | [build-img]: https://github.com/cristalhq/aconfig/workflows/build/badge.svg 91 | [build-url]: https://github.com/cristalhq/aconfig/actions 92 | [pkg-img]: https://pkg.go.dev/badge/cristalhq/aconfig 93 | [pkg-url]: https://pkg.go.dev/github.com/cristalhq/aconfig 94 | [version-img]: https://img.shields.io/github/v/release/cristalhq/aconfig 95 | [version-url]: https://github.com/cristalhq/aconfig/releases 96 | -------------------------------------------------------------------------------- /aconfig.go: -------------------------------------------------------------------------------- 1 | package aconfig 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // Loader of user configuration. 13 | type Loader struct { 14 | config Config 15 | dst any 16 | parser *structParser 17 | fields []*fieldData 18 | fsys fs.FS 19 | flagSet *flag.FlagSet 20 | errInit error 21 | } 22 | 23 | // Config to configure configuration loader. 24 | type Config struct { 25 | // NewParser set to true enables a new and better struct parser. 26 | // Default is false because there might be bugs. 27 | // In the future new parser will be enabled by default. 28 | NewParser bool 29 | 30 | SkipDefaults bool // SkipDefaults set to true will not load config from 'default' tag. 31 | SkipFiles bool // SkipFiles set to true will not load config from files. 32 | SkipEnv bool // SkipEnv set to true will not load config from environment variables. 33 | SkipFlags bool // SkipFlags set to true will not load config from flag parameters. 34 | 35 | EnvPrefix string // EnvPrefix for environment variables. 36 | FlagPrefix string // FlagPrefix for flag parameters. 37 | 38 | // envDelimiter for environment variables. Is always "_" due to env-var format. 39 | // Also unexported cause there is no sense to change it. 40 | envDelimiter string 41 | 42 | FlagDelimiter string // FlagDelimiter for flag parameters. If not set - default is ".". 43 | 44 | // AllFieldsRequired set to true will fail config loading if one of the fields was not set. 45 | // File, environment, flag must provide a value for the field. 46 | // If default is set and this option is enabled (or required tag is set) there will be an error. 47 | AllFieldRequired bool 48 | 49 | // AllowDuplicates set to true will not fail on duplicated names on fields (env, flag, etc...) 50 | AllowDuplicates bool 51 | 52 | // AllowUnknownFields set to true will not fail on unknown fields in files. 53 | AllowUnknownFields bool 54 | 55 | // AllowUnknownEnvs set to true will not fail on unknown environment variables (). 56 | // When false error is returned only when EnvPrefix isn't empty. 57 | AllowUnknownEnvs bool 58 | 59 | // AllowUnknownFlags set to true will not fail on unknown flag parameters (). 60 | // When false error is returned only when FlagPrefix isn't empty. 61 | AllowUnknownFlags bool 62 | 63 | // DontGenerateTags disables tag generation for JSON, YAML, TOML file formats. 64 | DontGenerateTags bool 65 | 66 | // FailOnFileNotFound will stop Loader on a first not found file from Files field in this structure. 67 | FailOnFileNotFound bool 68 | 69 | // FileSystem from which files will be loaded. Default is nil (OS file system). 70 | FileSystem fs.FS 71 | 72 | // MergeFiles set to true will collect all the entries from all the given files. 73 | // Easy wat to cobine base.yaml with prod.yaml 74 | MergeFiles bool 75 | 76 | // FileFlag the name of the flag that defines the path to the configuration file passed through the CLI. 77 | // (To make it easier to transfer the config file via flags.) 78 | FileFlag string 79 | 80 | // Files from which config should be loaded. 81 | Files []string 82 | 83 | // Envs hold the environment variable from which envs will be parsed. 84 | // By default is nil and then os.Environ() will be used. 85 | Envs []string 86 | 87 | // Args hold the command-line arguments from which flags will be parsed. 88 | // By default is nil and then os.Args will be used. 89 | // Unless loader.Flags() will be explicitly parsed by the user. 90 | Args []string 91 | 92 | // FileDecoders to enable other than JSON file formats and prevent additional dependencies. 93 | // Add required submodules to the go.mod and register them in this field. 94 | // Example: 95 | // FileDecoders: map[string]aconfig.FileDecoder{ 96 | // ".yaml": aconfigyaml.New(), 97 | // ".toml": aconfigtoml.New(), 98 | // ".env": aconfigdotenv.New(), 99 | // } 100 | FileDecoders map[string]FileDecoder 101 | } 102 | 103 | // FileDecoder is used to read config from files. See aconfig submodules. 104 | type FileDecoder interface { 105 | Format() string 106 | DecodeFile(filename string) (map[string]any, error) 107 | // Init(fsys fs.FS) 108 | } 109 | 110 | // Field of the user configuration structure. 111 | // Done as an interface to export less things in lib. 112 | type Field interface { 113 | // Name of the field. 114 | Name() string 115 | 116 | // Tag returns a given tag for a field. 117 | Tag(tag string) string 118 | 119 | // Parent of the current node. 120 | Parent() (Field, bool) 121 | } 122 | 123 | // LoaderFor creates a new Loader based on a given configuration structure. 124 | // Supports only non-nil structures. 125 | func LoaderFor(dst any, cfg Config) *Loader { 126 | assertStruct(dst) 127 | 128 | l := &Loader{ 129 | dst: dst, 130 | config: cfg, 131 | } 132 | l.init() 133 | return l 134 | } 135 | 136 | func (l *Loader) init() { 137 | l.config.envDelimiter = "_" 138 | 139 | if l.config.FlagDelimiter == "" { 140 | l.config.FlagDelimiter = "." 141 | } 142 | 143 | if l.config.EnvPrefix != "" { 144 | l.config.EnvPrefix += l.config.envDelimiter 145 | } 146 | if l.config.FlagPrefix != "" { 147 | l.config.FlagPrefix += l.config.FlagDelimiter 148 | } 149 | 150 | l.fsys = &fsOrOS{l.config.FileSystem} 151 | 152 | if _, ok := l.config.FileDecoders[".json"]; !ok { 153 | if l.config.FileDecoders == nil { 154 | l.config.FileDecoders = map[string]FileDecoder{} 155 | } 156 | l.config.FileDecoders[".json"] = &jsonDecoder{} 157 | } 158 | for _, dec := range l.config.FileDecoders { 159 | dec, ok := dec.(interface{ Init(fs.FS) }) 160 | if !ok { 161 | continue 162 | } 163 | dec.Init(l.fsys) 164 | } 165 | 166 | if l.config.Envs == nil { 167 | l.config.Envs = os.Environ() 168 | } 169 | if l.config.Args == nil { 170 | l.config.Args = os.Args[1:] 171 | } 172 | 173 | if l.config.NewParser { 174 | l.parser = newStructParser(l.config) 175 | if err := l.parser.parseStruct(l.dst); err != nil { 176 | l.errInit = err 177 | return 178 | } 179 | } else { 180 | l.fields = l.getFields(l.dst) 181 | } 182 | 183 | l.flagSet = flag.NewFlagSet(l.config.FlagPrefix, flag.ContinueOnError) 184 | if !l.config.SkipFlags { 185 | names := make(map[string]bool, len(l.fields)) 186 | if l.config.NewParser { 187 | l.flagSet = l.parser.flagSet 188 | } else { 189 | for _, field := range l.fields { 190 | flagName := l.fullTag(l.config.FlagPrefix, field, "flag") 191 | if flagName == "" { 192 | continue 193 | } 194 | if names[flagName] && !l.config.AllowDuplicates { 195 | l.errInit = fmt.Errorf("duplicate flag %q", flagName) 196 | return 197 | } 198 | names[flagName] = true 199 | l.flagSet.String(flagName, field.Tag("default"), field.Tag("usage")) 200 | } 201 | } 202 | } 203 | 204 | if l.config.FileFlag != "" { 205 | // TODO: should be prefixed ? 206 | l.flagSet.String(l.config.FileFlag, "", "config file param") 207 | } 208 | } 209 | 210 | // Flags returngs flag.FlagSet to create your own flags. 211 | // FlagSet name is Config.FlagPrefix and error handling is set to ContinueOnError. 212 | func (l *Loader) Flags() *flag.FlagSet { 213 | return l.flagSet 214 | } 215 | 216 | // WalkFields iterates over configuration fields. 217 | // Easy way to create documentation or user-friendly help. 218 | func (l *Loader) WalkFields(fn func(f Field) bool) { 219 | for _, f := range l.fields { 220 | if !fn(f) { 221 | return 222 | } 223 | } 224 | } 225 | 226 | // Load configuration into a given param. 227 | func (l *Loader) Load() error { 228 | if l.errInit != nil { 229 | return fmt.Errorf("init loader: %w", l.errInit) 230 | } 231 | if err := l.loadConfig(); err != nil { 232 | return fmt.Errorf("load config: %w", err) 233 | } 234 | return nil 235 | } 236 | 237 | func (l *Loader) loadConfig() error { 238 | if err := l.parseFlags(); err != nil { 239 | return err 240 | } 241 | if err := l.loadSources(); err != nil { 242 | return err 243 | } 244 | if err := l.checkRequired(); err != nil { 245 | return err 246 | } 247 | return nil 248 | } 249 | 250 | func (l *Loader) parseFlags() error { 251 | // TODO: too simple? 252 | if l.flagSet.Parsed() || l.config.SkipFlags { 253 | return nil 254 | } 255 | return l.flagSet.Parse(l.config.Args) 256 | } 257 | 258 | func (l *Loader) loadSources() error { 259 | if !l.config.SkipDefaults { 260 | if err := l.loadDefaults(); err != nil { 261 | return fmt.Errorf("load defaults: %w", err) 262 | } 263 | } 264 | if !l.config.SkipFiles { 265 | if err := l.loadFiles(); err != nil { 266 | return fmt.Errorf("load files: %w", err) 267 | } 268 | } 269 | if !l.config.SkipEnv { 270 | if err := l.loadEnvironment(); err != nil { 271 | return fmt.Errorf("load environment: %w", err) 272 | } 273 | } 274 | if !l.config.SkipFlags { 275 | if err := l.loadFlags(); err != nil { 276 | return fmt.Errorf("load flags: %w", err) 277 | } 278 | } 279 | 280 | if l.config.NewParser { 281 | if err := l.parser.apply(l.dst); err != nil { 282 | return fmt.Errorf("apply: %w", err) 283 | } 284 | } 285 | return nil 286 | } 287 | 288 | func (l *Loader) checkRequired() error { 289 | missedFields := []string{} 290 | for _, field := range l.fields { 291 | if field.isSet { 292 | continue 293 | } 294 | if field.isRequired || l.config.AllFieldRequired { 295 | missedFields = append(missedFields, field.name) 296 | } 297 | } 298 | 299 | if len(missedFields) == 0 { 300 | return nil 301 | } 302 | return fmt.Errorf("fields required but not set: %s", strings.Join(missedFields, ",")) 303 | } 304 | 305 | func (l *Loader) loadDefaults() error { 306 | if l.config.NewParser { 307 | return nil 308 | } 309 | 310 | for _, field := range l.fields { 311 | defaultValue := field.Tag("default") 312 | if err := l.setFieldData(field, defaultValue); err != nil { 313 | return err 314 | } 315 | field.isSet = (defaultValue != "") 316 | } 317 | return nil 318 | } 319 | 320 | func (l *Loader) loadFiles() error { 321 | if l.config.FileFlag != "" { 322 | if err := l.loadFileFlag(); err != nil { 323 | return err 324 | } 325 | } 326 | 327 | for _, file := range l.config.Files { 328 | if _, err := fs.Stat(l.fsys, file); os.IsNotExist(err) { 329 | if l.config.FailOnFileNotFound { 330 | return err 331 | } 332 | continue 333 | } 334 | 335 | if err := l.loadFile(file); err != nil { 336 | return err 337 | } 338 | 339 | if !l.config.MergeFiles { 340 | break 341 | } 342 | } 343 | return nil 344 | } 345 | 346 | func (l *Loader) loadFile(file string) error { 347 | ext := strings.ToLower(filepath.Ext(file)) 348 | decoder, ok := l.config.FileDecoders[ext] 349 | if !ok { 350 | return fmt.Errorf("file format %q is not supported", ext) 351 | } 352 | 353 | actualFields, err := decoder.DecodeFile(file) 354 | if err != nil { 355 | return err 356 | } 357 | 358 | tag := decoder.Format() 359 | 360 | if l.config.NewParser { 361 | if err := l.parser.applyLevel(tag, actualFields); err != nil { 362 | return fmt.Errorf("apply %s: %w", tag, err) 363 | } 364 | return nil 365 | } 366 | 367 | for _, field := range l.fields { 368 | name := l.fullTag("", field, tag) 369 | if name == "" { 370 | continue 371 | } 372 | value, ok := actualFields[name] 373 | if !ok { 374 | actualFields = find(actualFields, name) 375 | value, ok = actualFields[name] 376 | if !ok { 377 | continue 378 | } 379 | } 380 | 381 | if err := l.setFieldData(field, value); err != nil { 382 | return err 383 | } 384 | field.isSet = true 385 | delete(actualFields, name) 386 | } 387 | 388 | if !l.config.AllowUnknownFields { 389 | for env := range actualFields { 390 | return fmt.Errorf("unknown field in file %q: %s (see AllowUnknownFields config param)", file, env) 391 | } 392 | } 393 | return nil 394 | } 395 | 396 | func (l *Loader) loadFileFlag() error { 397 | fileFlag := getActualFlag(l.config.FileFlag, l.flagSet) 398 | if fileFlag == nil { 399 | return nil 400 | } 401 | 402 | configFile := fileFlag.Value.String() 403 | if configFile == "" { 404 | return fmt.Errorf("%s should not be empty", l.config.FileFlag) 405 | } 406 | 407 | if l.config.MergeFiles { 408 | l.config.Files = append(l.config.Files, configFile) 409 | } else { 410 | l.config.Files = []string{configFile} 411 | } 412 | return nil 413 | } 414 | 415 | func (l *Loader) loadEnvironment() error { 416 | actualEnvs := getEnv(l.config.Envs) 417 | dupls := make(map[string]struct{}) 418 | 419 | if l.config.NewParser { 420 | if err := l.parser.applyFlat("env", actualEnvs); err != nil { 421 | return fmt.Errorf("apply env: %w", err) 422 | } 423 | return nil 424 | } 425 | 426 | for _, field := range l.fields { 427 | envName := l.fullTag(l.config.EnvPrefix, field, "env") 428 | if envName == "" { 429 | continue 430 | } 431 | if err := l.setField(field, envName, actualEnvs, dupls); err != nil { 432 | return err 433 | } 434 | } 435 | return l.postEnvCheck(actualEnvs, dupls) 436 | } 437 | 438 | func (l *Loader) postEnvCheck(values map[string]any, dupls map[string]struct{}) error { 439 | if l.config.AllowUnknownEnvs || l.config.EnvPrefix == "" { 440 | return nil 441 | } 442 | for name := range dupls { 443 | delete(values, name) 444 | } 445 | for env := range values { 446 | if strings.HasPrefix(env, l.config.EnvPrefix) { 447 | return fmt.Errorf("unknown environment var %s (see AllowUnknownEnvs config param)", env) 448 | } 449 | } 450 | return nil 451 | } 452 | 453 | func (l *Loader) loadFlags() error { 454 | actualFlags := getFlags(l.flagSet) 455 | dupls := make(map[string]struct{}) 456 | 457 | if l.config.NewParser { 458 | if err := l.parser.applyFlat("flag", actualFlags); err != nil { 459 | return fmt.Errorf("apply flag: %w", err) 460 | } 461 | return nil 462 | } 463 | 464 | for _, field := range l.fields { 465 | flagName := l.fullTag(l.config.FlagPrefix, field, "flag") 466 | if flagName == "" { 467 | continue 468 | } 469 | if err := l.setField(field, flagName, actualFlags, dupls); err != nil { 470 | return err 471 | } 472 | } 473 | return l.postFlagCheck(actualFlags, dupls) 474 | } 475 | 476 | func (l *Loader) postFlagCheck(values map[string]any, dupls map[string]struct{}) error { 477 | if l.config.AllowUnknownFlags || l.config.FlagPrefix == "" { 478 | return nil 479 | } 480 | for name := range dupls { 481 | delete(values, name) 482 | } 483 | for flag := range values { 484 | if strings.HasPrefix(flag, l.config.FlagPrefix) { 485 | return fmt.Errorf("unknown flag %s (see AllowUnknownFlags config param)", flag) 486 | } 487 | } 488 | return nil 489 | } 490 | 491 | // TODO(cristaloleg): revisit. 492 | func (l *Loader) setField(field *fieldData, name string, values map[string]any, dupls map[string]struct{}) error { 493 | if !l.config.AllowDuplicates { 494 | if _, ok := dupls[name]; ok { 495 | return fmt.Errorf("field %q is duplicated", name) 496 | } 497 | dupls[name] = struct{}{} 498 | } 499 | 500 | val, ok := values[name] 501 | if !ok { 502 | return nil 503 | } 504 | 505 | if err := l.setFieldData(field, val); err != nil { 506 | return err 507 | } 508 | 509 | field.isSet = true 510 | if !l.config.AllowDuplicates { 511 | delete(values, name) 512 | } 513 | return nil 514 | } 515 | -------------------------------------------------------------------------------- /aconfig_test.go: -------------------------------------------------------------------------------- 1 | package aconfig 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io" 7 | "net/url" 8 | "os" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | "testing/fstest" 13 | "time" 14 | ) 15 | 16 | var newParser = os.Getenv("ACONFIG_NEW") == "true" 17 | 18 | func TestTrueSkip(t *testing.T) { 19 | var cfg TestConfig 20 | loader := LoaderFor(&cfg, Config{ 21 | NewParser: newParser, 22 | SkipDefaults: true, 23 | SkipFiles: true, 24 | SkipEnv: true, 25 | SkipFlags: true, 26 | }) 27 | if err := loader.Load(); err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | want := TestConfig{} 32 | 33 | if have := cfg; !reflect.DeepEqual(have, want) { 34 | fmt.Printf("have: %+v\n", *have.Int) 35 | t.Fatalf("\nhave: %+v\nwant: %+v", have, want) 36 | } 37 | } 38 | 39 | func Test_parse(t *testing.T) { 40 | var cfg TestConfig2 41 | 42 | loader := LoaderFor(&cfg, Config{ 43 | NewParser: newParser, 44 | SkipEnv: true, 45 | SkipFlags: true, 46 | }) 47 | if err := loader.Load(); err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | // fmt.Printf("\nresult: %+v\n", cfg) 52 | // fmt.Printf("b: %v c: %+v\n", *cfg.B, cfg.C) 53 | } 54 | 55 | type TestConfig2 struct { 56 | A int `default:"1"` 57 | B *int32 `default:"10" json:"boom_boom"` 58 | C *int32 `env:"ccc"` 59 | D string `default:"str"` 60 | E struct { 61 | Bar int `default:"42"` 62 | Foo string `default:"foo"` 63 | } 64 | F map[string]int `default:"1:20,3:4"` 65 | F2 map[int]string `default:"1:2,3:40"` 66 | G map[string]struct { 67 | Baz int `default:"1234"` 68 | } // `default:"1:1234"` 69 | H []string `default:"ab,cd,ef"` 70 | H2 []int `default:"1,2,3"` 71 | I map[string][]string `default:"1:a-b,2:c-d,3:e-f"` 72 | J []struct { 73 | Quzz int 74 | } //`default:"1,2,3,4"` 75 | Y X 76 | X 77 | } 78 | type X struct { 79 | Xex string `default:"XEX" env:"XEXEXE" flag:"axaxa"` 80 | } 81 | 82 | type LogLevel int8 83 | 84 | func (l *LogLevel) UnmarshalText(text []byte) error { 85 | switch string(text) { 86 | case "debug": 87 | *l = -1 88 | case "info": 89 | *l = 0 90 | case "warn": 91 | *l = 1 92 | case "error": 93 | *l = 2 94 | default: 95 | return fmt.Errorf("unknown log level: %s", text) 96 | } 97 | return nil 98 | } 99 | 100 | func TestDefaults(t *testing.T) { 101 | // type TestConfig struct { 102 | // Str string `default:"str-def"` 103 | // Bytes []byte `default:"bytes-def"` 104 | // Int *int32 `default:"123"` 105 | // HTTPPort int `default:"8080"` 106 | // Param int // no default tag, so default value 107 | // ParamPtr *int // no default tag, so default value 108 | // Sub SubConfig 109 | // Anon struct { 110 | // IsAnon bool `default:"true"` 111 | // } 112 | // StrSlice []string `default:"1,2,3" usage:"just pass strings"` 113 | // Slice []int `default:"1,2,3" usage:"just pass elements"` 114 | // Map1 map[string]int `default:"a:1,b:2,c:3"` 115 | // Map2 map[int]string `default:"1:a,2:b,3:c"` 116 | // EmbeddedConfig 117 | // } 118 | 119 | var cfg TestConfig 120 | loader := LoaderFor(&cfg, Config{ 121 | NewParser: newParser, 122 | SkipFiles: true, 123 | SkipEnv: true, 124 | SkipFlags: true, 125 | }) 126 | failIfErr(t, loader.Load()) 127 | 128 | want := TestConfig{ 129 | Str: "str-def", 130 | Bytes: []byte("bytes-def"), 131 | Int: int32Ptr(123), 132 | HTTPPort: 8080, 133 | Sub: SubConfig{Float: 123.123}, 134 | Anon: struct { 135 | IsAnon bool `default:"true"` 136 | }{IsAnon: true}, 137 | StrSlice: []string{"1", "2", "3"}, 138 | Slice: []int{1, 2, 3}, 139 | Map1: map[string]int{"a": 1, "b": 2, "c": 3}, 140 | Map2: map[int]string{1: "a", 2: "b", 3: "c"}, 141 | EmbeddedConfig: EmbeddedConfig{Em: "em-def"}, 142 | } 143 | mustEqual(t, cfg, want) 144 | } 145 | 146 | func TestDefaults_AllTypes(t *testing.T) { 147 | type AllTypesConfig struct { 148 | Bool bool `default:"true"` 149 | String string `default:"str"` 150 | 151 | Int int `default:"1"` 152 | Int8 int8 `default:"12"` 153 | Int16 int16 `default:"123"` 154 | Int32 int32 `default:"13"` 155 | Int64 int64 `default:"23"` 156 | 157 | Uint uint `default:"1234"` 158 | Uint8 uint8 `default:"124"` 159 | Uint16 uint16 `default:"134"` 160 | Uint32 uint32 `default:"234"` 161 | Uint64 uint64 `default:"24"` 162 | 163 | Float32 float32 `default:"1234.213"` 164 | Float64 float64 `default:"1234.234"` 165 | 166 | Dur time.Duration `default:"1h2m3s"` 167 | // Time time.Time `default:"2000-04-05 10:20:30 +0000 UTC"` 168 | 169 | Level LogLevel `default:"warn"` 170 | } 171 | 172 | var cfg AllTypesConfig 173 | loader := LoaderFor(&cfg, Config{ 174 | NewParser: newParser, 175 | SkipFiles: true, 176 | SkipEnv: true, 177 | SkipFlags: true, 178 | }) 179 | failIfErr(t, loader.Load()) 180 | 181 | want := AllTypesConfig{ 182 | Bool: true, 183 | String: "str", 184 | Int: 1, 185 | Int8: 12, 186 | Int16: 123, 187 | Int32: 13, 188 | Int64: 23, 189 | Uint: 1234, 190 | Uint8: 124, 191 | Uint16: 134, 192 | Uint32: 234, 193 | Uint64: 24, 194 | Float32: 1234.213, 195 | Float64: 1234.234, 196 | Dur: time.Hour + 2*time.Minute + 3*time.Second, 197 | // TODO: support time 198 | // Time :2000-04-05 10:20:30 +0000 UTC, 199 | Level: LogLevel(1), 200 | } 201 | mustEqual(t, cfg, want) 202 | } 203 | 204 | func TestDefaults_OtherNumberFormats(t *testing.T) { 205 | type OtherNumberFormats struct { 206 | Int int `default:"0b111"` 207 | Int8 int8 `default:"0o123"` 208 | Int8x2 int8 `default:"0123"` 209 | Int16 int16 `default:"0x123"` 210 | 211 | Uint uint `default:"0b111"` 212 | Uint8 uint8 `default:"0o123"` 213 | Uint16 uint16 `default:"0123"` 214 | Uint32 uint32 `default:"0x123"` 215 | } 216 | 217 | var cfg OtherNumberFormats 218 | loader := LoaderFor(&cfg, Config{ 219 | NewParser: newParser, 220 | SkipFiles: true, 221 | SkipEnv: true, 222 | SkipFlags: true, 223 | }) 224 | failIfErr(t, loader.Load()) 225 | 226 | want := OtherNumberFormats{ 227 | Int: 7, 228 | Int8: 83, 229 | Int8x2: 83, 230 | Int16: 291, 231 | 232 | Uint: 7, 233 | Uint8: 83, 234 | Uint16: 83, 235 | Uint32: 291, 236 | } 237 | mustEqual(t, cfg, want) 238 | } 239 | 240 | func TestJSON(t *testing.T) { 241 | const filepath = "testfile.json" 242 | 243 | var cfg structConfig 244 | loader := LoaderFor(&cfg, Config{ 245 | NewParser: newParser, 246 | SkipDefaults: true, 247 | SkipEnv: true, 248 | SkipFlags: true, 249 | Files: []string{filepath}, 250 | FileSystem: fstest.MapFS{filepath: testfile}, 251 | }) 252 | failIfErr(t, loader.Load()) 253 | 254 | want := wantConfig 255 | mustEqual(t, cfg, want) 256 | } 257 | 258 | func TestJSONWithOmitempty(t *testing.T) { 259 | const filepath = "testfile.json" 260 | 261 | var cfg struct { 262 | APIKey string `json:"b,omitempty"` 263 | } 264 | loader := LoaderFor(&cfg, Config{ 265 | NewParser: newParser, 266 | SkipDefaults: true, 267 | SkipEnv: true, 268 | SkipFlags: true, 269 | AllowUnknownFields: true, 270 | Files: []string{filepath}, 271 | FileSystem: fstest.MapFS{filepath: testfile}, 272 | }) 273 | failIfErr(t, loader.Load()) 274 | } 275 | 276 | func TestCustomFile(t *testing.T) { 277 | const filepath = "custom.config" 278 | 279 | var cfg structConfig 280 | loader := LoaderFor(&cfg, Config{ 281 | NewParser: newParser, 282 | SkipDefaults: true, 283 | SkipEnv: true, 284 | SkipFlags: true, 285 | Files: []string{filepath}, 286 | FileSystem: fstest.MapFS{filepath: testfile}, 287 | FileDecoders: map[string]FileDecoder{ 288 | ".config": &jsonDecoder{}, 289 | }, 290 | }) 291 | failIfErr(t, loader.Load()) 292 | 293 | want := wantConfig 294 | mustEqual(t, cfg, want) 295 | } 296 | 297 | func TestFile(t *testing.T) { 298 | filepath := "testdata/config.json" 299 | 300 | var cfg TestConfig 301 | loader := LoaderFor(&cfg, Config{ 302 | NewParser: newParser, 303 | SkipDefaults: true, 304 | SkipEnv: true, 305 | SkipFlags: true, 306 | Files: []string{filepath}, 307 | }) 308 | failIfErr(t, loader.Load()) 309 | 310 | want := TestConfig{ 311 | Str: "str-json", 312 | Bytes: []byte("Ynl0ZXMtanNvbg=="), 313 | Int: int32Ptr(101), 314 | HTTPPort: 65000, 315 | Sub: SubConfig{ 316 | Float: 999.111, 317 | }, 318 | Anon: struct { 319 | IsAnon bool `default:"true"` 320 | }{ 321 | IsAnon: true, 322 | }, 323 | } 324 | mustEqual(t, cfg, want) 325 | } 326 | 327 | //go:embed testdata 328 | var configEmbed embed.FS 329 | 330 | func TestFileEmbed(t *testing.T) { 331 | filepath := "testdata/config.json" 332 | 333 | var cfg TestConfig 334 | loader := LoaderFor(&cfg, Config{ 335 | NewParser: newParser, 336 | SkipDefaults: true, 337 | SkipEnv: true, 338 | SkipFlags: true, 339 | Files: []string{filepath}, 340 | FileSystem: configEmbed, 341 | }) 342 | failIfErr(t, loader.Load()) 343 | 344 | want := TestConfig{ 345 | Str: "str-json", 346 | Bytes: []byte("Ynl0ZXMtanNvbg=="), 347 | Int: int32Ptr(101), 348 | HTTPPort: 65000, 349 | Sub: SubConfig{ 350 | Float: 999.111, 351 | }, 352 | Anon: struct { 353 | IsAnon bool `default:"true"` 354 | }{ 355 | IsAnon: true, 356 | }, 357 | } 358 | mustEqual(t, cfg, want) 359 | } 360 | 361 | func TestFileMerging(t *testing.T) { 362 | file1 := "testdata/config1.json" 363 | file2 := "testdata/config2.json" 364 | file3 := "testdata/config3.json" 365 | 366 | var cfg TestConfig 367 | loader := LoaderFor(&cfg, Config{ 368 | NewParser: newParser, 369 | SkipDefaults: true, 370 | SkipEnv: true, 371 | SkipFlags: true, 372 | MergeFiles: true, 373 | Files: []string{file1, file2, file3}, 374 | }) 375 | failIfErr(t, loader.Load()) 376 | 377 | want := TestConfig{ 378 | Str: "111", 379 | HTTPPort: 222, 380 | Sub: SubConfig{ 381 | Float: 333.333, 382 | }, 383 | } 384 | mustEqual(t, cfg, want) 385 | } 386 | 387 | func TestFileFlag(t *testing.T) { 388 | file1 := "testdata/config1.json" 389 | 390 | flags := []string{ 391 | "-file_flag=testdata/config2.json", 392 | } 393 | 394 | var cfg TestConfig 395 | loader := LoaderFor(&cfg, Config{ 396 | NewParser: newParser, 397 | SkipDefaults: true, 398 | SkipEnv: true, 399 | MergeFiles: true, 400 | FileFlag: "file_flag", 401 | Files: []string{file1}, 402 | Args: flags, 403 | }) 404 | failIfErr(t, loader.Load()) 405 | 406 | want := TestConfig{ 407 | Str: "111", 408 | HTTPPort: 222, 409 | } 410 | mustEqual(t, cfg, want) 411 | } 412 | 413 | func TestBadFileFlag(t *testing.T) { 414 | flags := []string{ 415 | "-file_flag=", 416 | } 417 | 418 | var cfg TestConfig 419 | loader := LoaderFor(&cfg, Config{ 420 | NewParser: newParser, 421 | SkipDefaults: true, 422 | SkipEnv: true, 423 | FileFlag: "file_flag", 424 | Args: flags, 425 | }) 426 | failIfOk(t, loader.Load()) 427 | } 428 | 429 | func TestNoFileFlagValue(t *testing.T) { 430 | file1 := "testdata/config1.json" 431 | 432 | var cfg TestConfig 433 | loader := LoaderFor(&cfg, Config{ 434 | NewParser: newParser, 435 | SkipDefaults: true, 436 | SkipEnv: true, 437 | FileFlag: "file_flag", 438 | Files: []string{file1}, 439 | Args: []string{}, // no file_flag 440 | }) 441 | failIfErr(t, loader.Load()) 442 | 443 | want := TestConfig{ 444 | Str: "111", 445 | HTTPPort: 111, 446 | } 447 | mustEqual(t, cfg, want) 448 | } 449 | 450 | func TestEnv(t *testing.T) { 451 | t.Setenv("TST_STR", "str-env") 452 | t.Setenv("TST_BYTES", "bytes-env") 453 | t.Setenv("TST_INT", "121") 454 | t.Setenv("TST_HTTP_PORT", "3000") 455 | t.Setenv("TST_SUB_FLOAT", "222.333") 456 | t.Setenv("TST_ANON_IS_ANON", "true") 457 | t.Setenv("TST_EM", "em-env") 458 | defer os.Clearenv() 459 | 460 | // type TestConfig struct { 461 | // Sub SubConfig 462 | // } 463 | var cfg TestConfig 464 | loader := LoaderFor(&cfg, Config{ 465 | NewParser: newParser, 466 | SkipDefaults: true, 467 | SkipFiles: true, 468 | SkipFlags: true, 469 | EnvPrefix: "TST", 470 | }) 471 | failIfErr(t, loader.Load()) 472 | 473 | want := TestConfig{ 474 | Str: "str-env", 475 | Bytes: []byte("bytes-env"), 476 | Int: int32Ptr(121), 477 | HTTPPort: 3000, 478 | Sub: SubConfig{ 479 | Float: 222.333, 480 | }, 481 | Anon: struct { 482 | IsAnon bool `default:"true"` 483 | }{ 484 | IsAnon: true, 485 | }, 486 | EmbeddedConfig: EmbeddedConfig{ 487 | Em: "em-env", 488 | }, 489 | } 490 | mustEqual(t, cfg, want) 491 | } 492 | 493 | func TestFlag(t *testing.T) { 494 | var cfg TestConfig 495 | loader := LoaderFor(&cfg, Config{ 496 | NewParser: newParser, 497 | SkipDefaults: true, 498 | SkipFiles: true, 499 | SkipEnv: true, 500 | FlagPrefix: "tst", 501 | }) 502 | 503 | flags := []string{ 504 | "-tst.str=str-flag", 505 | "-tst.bytes=Ynl0ZXMtZmxhZw==", 506 | "-tst.int=1001", 507 | "-tst.http_port=30000", 508 | "-tst.sub.float=123.321", 509 | "-tst.anon.is_anon=true", 510 | "-tst.em=em-flag", 511 | } 512 | 513 | failIfErr(t, loader.Flags().Parse(flags)) 514 | 515 | failIfErr(t, loader.Load()) 516 | 517 | want := TestConfig{ 518 | Str: "str-flag", 519 | Bytes: []byte("Ynl0ZXMtZmxhZw=="), 520 | Int: int32Ptr(1001), 521 | HTTPPort: 30000, 522 | Sub: SubConfig{ 523 | Float: 123.321, 524 | }, 525 | Anon: struct { 526 | IsAnon bool `default:"true"` 527 | }{ 528 | IsAnon: true, 529 | }, 530 | EmbeddedConfig: EmbeddedConfig{ 531 | Em: "em-flag", 532 | }, 533 | } 534 | mustEqual(t, cfg, want) 535 | } 536 | 537 | func TestExactName(t *testing.T) { 538 | t.Setenv("STR", "str-env") 539 | t.Setenv("TST_STR", "bar-env") 540 | defer os.Clearenv() 541 | 542 | type Foo struct { 543 | String string `env:"STR,exact"` 544 | } 545 | type ExactConfig struct { 546 | Foo Foo 547 | Bar string `env:"STR"` 548 | } 549 | var cfg ExactConfig 550 | 551 | loader := LoaderFor(&cfg, Config{ 552 | NewParser: newParser, 553 | SkipDefaults: true, 554 | SkipFiles: true, 555 | SkipFlags: true, 556 | AllowUnknownEnvs: true, 557 | EnvPrefix: "TST", 558 | }) 559 | failIfErr(t, loader.Load()) 560 | 561 | want := ExactConfig{ 562 | Foo: Foo{ 563 | String: "str-env", 564 | }, 565 | Bar: "bar-env", 566 | } 567 | mustEqual(t, cfg, want) 568 | } 569 | 570 | func TestSkipName(t *testing.T) { 571 | t.Setenv("STR", "str-env") 572 | t.Setenv("BAR", "bar-env") 573 | defer os.Clearenv() 574 | 575 | type Foo struct { 576 | String string `default:"str" env:"STR"` 577 | } 578 | type ExactConfig struct { 579 | Foo Foo `env:"-"` 580 | Bar string `default:"def" env:"-"` 581 | } 582 | var cfg ExactConfig 583 | 584 | loader := LoaderFor(&cfg, Config{ 585 | NewParser: newParser, 586 | SkipFiles: true, 587 | SkipFlags: true, 588 | }) 589 | failIfErr(t, loader.Load()) 590 | 591 | want := ExactConfig{ 592 | Foo: Foo{ 593 | String: "str-env", 594 | }, 595 | Bar: "def", 596 | } 597 | mustEqual(t, cfg, want) 598 | } 599 | 600 | func TestDuplicatedName(t *testing.T) { 601 | t.Setenv("FOO_BAR", "str-env") 602 | defer os.Clearenv() 603 | 604 | type Foo struct { 605 | Bar string 606 | } 607 | type ExactConfig struct { 608 | Foo Foo 609 | FooBar string 610 | } 611 | var cfg ExactConfig 612 | 613 | loader := LoaderFor(&cfg, Config{ 614 | NewParser: newParser, 615 | SkipFlags: true, 616 | AllowDuplicates: true, 617 | }) 618 | failIfErr(t, loader.Load()) 619 | 620 | want := ExactConfig{ 621 | Foo: Foo{ 622 | Bar: "str-env", 623 | }, 624 | FooBar: "str-env", 625 | } 626 | mustEqual(t, cfg, want) 627 | } 628 | 629 | func TestFailOnDuplicatedName(t *testing.T) { 630 | type Foo struct { 631 | Bar string 632 | } 633 | type ExactConfig struct { 634 | Foo Foo 635 | FooBar string 636 | } 637 | var cfg ExactConfig 638 | 639 | loader := LoaderFor(&cfg, Config{ 640 | NewParser: newParser, 641 | SkipFlags: true, 642 | }) 643 | 644 | err := loader.Load() 645 | failIfOk(t, err) 646 | 647 | if !strings.Contains(err.Error(), "is duplicated") { 648 | t.Fatalf("got %s", err.Error()) 649 | } 650 | } 651 | 652 | func TestFailOnDuplicatedFlag(t *testing.T) { 653 | type Foo struct { 654 | Bar string `flag:"yes"` 655 | Baz string `flag:"yes"` 656 | } 657 | 658 | err := LoaderFor(&Foo{}, Config{NewParser: newParser}).Load() 659 | failIfOk(t, err) 660 | 661 | want := `init loader: duplicate flag "yes"` 662 | mustEqual(t, err.Error(), want) 663 | } 664 | 665 | func TestUsage(t *testing.T) { 666 | loader := LoaderFor(&EmbeddedConfig{}, Config{ 667 | NewParser: newParser, 668 | }) 669 | 670 | var builder strings.Builder 671 | flags := loader.Flags() 672 | flags.SetOutput(&builder) 673 | flags.PrintDefaults() 674 | 675 | have := builder.String() 676 | want := ` -em string 677 | use... em...field. (default "em-def") 678 | ` 679 | mustEqual(t, have, want) 680 | } 681 | 682 | func TestBadDefauts(t *testing.T) { 683 | f := func(cfg any) { 684 | t.Helper() 685 | 686 | loader := LoaderFor(cfg, Config{ 687 | NewParser: newParser, 688 | SkipFiles: true, 689 | SkipEnv: true, 690 | SkipFlags: true, 691 | }) 692 | failIfOk(t, loader.Load()) 693 | } 694 | 695 | f(&struct { 696 | Bool bool `default:"omg"` 697 | }{}) 698 | 699 | f(&struct { 700 | Int int `default:"1a"` 701 | }{}) 702 | 703 | f(&struct { 704 | Int8 int8 `default:"12a"` 705 | }{}) 706 | 707 | f(&struct { 708 | Int16 int16 `default:"123a"` 709 | }{}) 710 | 711 | f(&struct { 712 | Int32 int32 `default:"13a"` 713 | }{}) 714 | 715 | f(&struct { 716 | Int64 int64 `default:"23a"` 717 | }{}) 718 | 719 | f(&struct { 720 | Uint uint `default:"1234a"` 721 | }{}) 722 | 723 | f(&struct { 724 | Uint8 uint8 `default:"124a"` 725 | }{}) 726 | 727 | f(&struct { 728 | Uint16 uint16 `default:"134a"` 729 | }{}) 730 | 731 | f(&struct { 732 | Uint32 uint32 `default:"234a"` 733 | }{}) 734 | 735 | f(&struct { 736 | Uint64 uint64 `default:"24a"` 737 | }{}) 738 | 739 | f(&struct { 740 | Float32 float32 `default:"1234x213"` 741 | }{}) 742 | 743 | f(&struct { 744 | Float64 float64 `default:"1234x234"` 745 | }{}) 746 | 747 | f(&struct { 748 | Dur time.Duration `default:"1h_2m3s"` 749 | }{}) 750 | 751 | f(&struct { 752 | Slice []int `default:"1,a,2"` 753 | }{}) 754 | 755 | f(&struct { 756 | Map map[string]int `default:"1:a;2:2"` 757 | }{}) 758 | 759 | f(&struct { 760 | Map map[int]string `default:"a:1;"` 761 | }{}) 762 | 763 | f(&struct { 764 | Map map[int]string `default:"a1"` 765 | }{}) 766 | 767 | f(&struct { 768 | Array [2]string `default:"a1"` 769 | }{}) 770 | } 771 | 772 | func TestBadFiles(t *testing.T) { 773 | f := func(filepath string) { 774 | t.Helper() 775 | t.Run(filepath, func(t *testing.T) { 776 | t.Helper() 777 | var cfg TestConfig 778 | loader := LoaderFor(&cfg, Config{ 779 | NewParser: newParser, 780 | SkipDefaults: true, 781 | SkipEnv: true, 782 | SkipFlags: true, 783 | FailOnFileNotFound: true, 784 | Files: []string{filepath}, 785 | FileSystem: fstest.MapFS{ 786 | "bad_config.json": &fstest.MapFile{Data: []byte(`{almost": "json`)}, 787 | "unknown.ext": &fstest.MapFile{}, 788 | }, 789 | }) 790 | failIfOk(t, loader.Load()) 791 | }) 792 | } 793 | 794 | f("no_such_file.json") 795 | f("bad_config.json") 796 | f("unknown.ext") 797 | } 798 | 799 | func TestFailOnFileNotFound(t *testing.T) { 800 | f := func(filepath string) { 801 | t.Helper() 802 | 803 | loader := LoaderFor(&TestConfig{}, Config{ 804 | NewParser: newParser, 805 | SkipDefaults: true, 806 | SkipEnv: true, 807 | SkipFlags: true, 808 | FailOnFileNotFound: false, 809 | Files: []string{filepath}, 810 | }) 811 | 812 | failIfErr(t, loader.Load()) 813 | } 814 | 815 | f("testdata/config.json") 816 | f("testdata/not_found.json") 817 | } 818 | 819 | func TestBadEnvs(t *testing.T) { 820 | t.Setenv("TST_HTTP_PORT", "30a00") 821 | defer os.Clearenv() 822 | 823 | loader := LoaderFor(&TestConfig{}, Config{ 824 | NewParser: newParser, 825 | SkipDefaults: true, 826 | SkipFiles: true, 827 | SkipFlags: true, 828 | EnvPrefix: "TST", 829 | }) 830 | 831 | failIfOk(t, loader.Load()) 832 | } 833 | 834 | func TestBadFlags(t *testing.T) { 835 | loader := LoaderFor(&TestConfig{}, Config{ 836 | NewParser: newParser, 837 | SkipDefaults: true, 838 | SkipFiles: true, 839 | SkipEnv: true, 840 | FlagPrefix: "tst", 841 | }) 842 | 843 | args := []string{"-tst.param=10a01"} 844 | 845 | failIfErr(t, loader.Flags().Parse(args)) 846 | failIfOk(t, loader.Load()) 847 | } 848 | 849 | func TestUnknownFields(t *testing.T) { 850 | filepath := "testdata/unknown_fields.json" 851 | 852 | var cfg TestConfig 853 | loader := LoaderFor(&cfg, Config{ 854 | NewParser: newParser, 855 | SkipDefaults: true, 856 | SkipEnv: true, 857 | SkipFlags: true, 858 | Files: []string{filepath}, 859 | }) 860 | 861 | err := loader.Load() 862 | failIfOk(t, err) 863 | 864 | if !strings.Contains(err.Error(), "unknown field in file") { 865 | t.Fatalf("got %s", err.Error()) 866 | } 867 | } 868 | 869 | func TestUnknownEnvs(t *testing.T) { 870 | t.Setenv("TST_STR", "defined") 871 | t.Setenv("TST_UNKNOWN", "42") 872 | t.Setenv("JUST_ENV", "JUST_VALUE") 873 | defer os.Clearenv() 874 | 875 | var cfg TestConfig 876 | loader := LoaderFor(&cfg, Config{ 877 | NewParser: newParser, 878 | SkipDefaults: true, 879 | SkipFiles: true, 880 | SkipFlags: true, 881 | EnvPrefix: "TST", 882 | }) 883 | 884 | err := loader.Load() 885 | failIfOk(t, err) 886 | 887 | if !strings.Contains(err.Error(), "unknown environment var") { 888 | t.Fatalf("got %s", err.Error()) 889 | } 890 | } 891 | 892 | func TestUnknownEnvsWithEmptyPrefix(t *testing.T) { 893 | t.Setenv("STR", "defined") 894 | t.Setenv("UNKNOWN", "42") 895 | defer os.Clearenv() 896 | 897 | var cfg TestConfig 898 | loader := LoaderFor(&cfg, Config{ 899 | NewParser: newParser, 900 | SkipDefaults: true, 901 | SkipFiles: true, 902 | SkipFlags: true, 903 | }) 904 | 905 | failIfErr(t, loader.Load()) 906 | } 907 | 908 | func TestUnknownFlags(t *testing.T) { 909 | loader := LoaderFor(&TestConfig{}, Config{ 910 | NewParser: newParser, 911 | SkipDefaults: true, 912 | SkipFiles: true, 913 | SkipEnv: true, 914 | FlagPrefix: "tst", 915 | }) 916 | 917 | flags := []string{ 918 | "-tst.str=str-flag", 919 | "-tst.unknown=1001", 920 | "-just_env=just_value", 921 | } 922 | 923 | // just for tests 924 | flagSet := loader.Flags() 925 | flagSet.SetOutput(io.Discard) 926 | 927 | // define flag with a loader's prefix which is unknown 928 | flagSet.Int("tst.unknown", 42, "") 929 | flagSet.String("just_env", "just_def", "") 930 | 931 | failIfErr(t, flagSet.Parse(flags)) 932 | 933 | err := loader.Load() 934 | failIfOk(t, err) 935 | 936 | if !strings.Contains(err.Error(), "unknown flag") { 937 | t.Fatalf("got %s", err.Error()) 938 | } 939 | } 940 | 941 | func TestUnknownFlagsWithEmptyPrefix(t *testing.T) { 942 | loader := LoaderFor(&TestConfig{}, Config{ 943 | NewParser: newParser, 944 | SkipDefaults: true, 945 | SkipFiles: true, 946 | SkipEnv: true, 947 | }) 948 | 949 | flags := []string{ 950 | "-str=str-flag", 951 | "-unknown=1001", 952 | } 953 | 954 | // just for tests 955 | flagSet := loader.Flags() 956 | flagSet.SetOutput(io.Discard) 957 | 958 | // define flag with a loader's prefix which is unknown 959 | flagSet.Int("unknown", 42, "") 960 | 961 | failIfErr(t, flagSet.Parse(flags)) 962 | failIfErr(t, loader.Load()) 963 | } 964 | 965 | // flag.FlagSet already fails on undefined flag. 966 | func TestUnknownFlagsStdlib(t *testing.T) { 967 | loader := LoaderFor(&TestConfig{}, Config{ 968 | NewParser: newParser, 969 | SkipDefaults: true, 970 | SkipFiles: true, 971 | SkipEnv: true, 972 | FlagPrefix: "tst", 973 | }) 974 | 975 | flags := []string{ 976 | "-tst.str=str-flag", 977 | "-tst.unknown=1001", 978 | } 979 | 980 | // just for tests 981 | flagSet := loader.Flags() 982 | flagSet.SetOutput(io.Discard) 983 | 984 | failIfOk(t, flagSet.Parse(flags)) 985 | } 986 | 987 | func TestCustomEnvsAndArgs(t *testing.T) { 988 | var cfg TestConfig 989 | loader := LoaderFor(&cfg, Config{ 990 | SkipDefaults: true, 991 | Envs: []string{"PARAM=2"}, 992 | Args: []string{"-str=4"}, 993 | }) 994 | 995 | failIfErr(t, loader.Load()) 996 | 997 | want := TestConfig{ 998 | Str: "4", 999 | Param: 2, 1000 | } 1001 | mustEqual(t, cfg, want) 1002 | } 1003 | 1004 | func TestCustomNames(t *testing.T) { 1005 | type TestConfig struct { 1006 | A int `default:"-1" env:"ONE"` 1007 | B int `default:"-1" flag:"two"` 1008 | C int `default:"-1" env:"three" flag:"four"` 1009 | } 1010 | 1011 | t.Setenv("ONE", "1") 1012 | t.Setenv("three", "3") 1013 | defer os.Clearenv() 1014 | 1015 | var cfg TestConfig 1016 | loader := LoaderFor(&cfg, Config{ 1017 | NewParser: newParser, 1018 | Args: []string{"-two=2", "-four=4"}, 1019 | }) 1020 | 1021 | failIfErr(t, loader.Load()) 1022 | 1023 | mustEqual(t, cfg.A, 1) 1024 | mustEqual(t, cfg.B, 2) 1025 | mustEqual(t, cfg.C, 4) 1026 | } 1027 | 1028 | func TestDontGenerateTags(t *testing.T) { 1029 | type testConfig struct { 1030 | A string `json:"aaa"` 1031 | B string `yaml:"aaa" toml:"bbb"` 1032 | DooDoo string 1033 | HTTPPort int `yaml:"port"` 1034 | D string `env:"aaa"` 1035 | E string `flag:"aaa"` 1036 | } 1037 | 1038 | want := map[string]string{ 1039 | "A::json": "aaa", 1040 | "B::yaml": "aaa", 1041 | "C::toml": "c", 1042 | "DooDoo::toml": "DooDoo", 1043 | "DooDoo::flag": "doo_doo", 1044 | "HTTPPort::flag": "http_port", 1045 | "HTTPPort::json": "HTTPPort", 1046 | "HTTPPort::yaml": "port", 1047 | "D::env": "aaa", 1048 | "E::flag": "aaa", 1049 | "E::json": "E", 1050 | } 1051 | cfg := Config{ 1052 | DontGenerateTags: true, 1053 | NewParser: newParser, 1054 | } 1055 | LoaderFor(&testConfig{}, cfg).WalkFields(func(f Field) bool { 1056 | for _, tag := range []string{"json", "yaml", "env", "flag"} { 1057 | k := f.Name() + "::" + tag 1058 | if v, ok := want[k]; ok && v != f.Tag(tag) { 1059 | t.Fatalf("%v: got %v, want %v", tag, f.Tag(tag), v) 1060 | return false 1061 | } 1062 | } 1063 | return true 1064 | }) 1065 | } 1066 | 1067 | func TestWalkFields(t *testing.T) { 1068 | if newParser { 1069 | t.Skip() 1070 | } 1071 | type TestConfig struct { 1072 | A int `default:"-1" env:"one" marco:"polo"` 1073 | B struct { 1074 | C int `default:"-1" flag:"two" usage:"pretty simple usage duh" json:"kek" yaml:"lel" toml:"mde"` 1075 | D struct { 1076 | E int `default:"-1" env:"three" json:"kek" yaml:"lel" toml:"mde"` 1077 | } 1078 | } 1079 | } 1080 | 1081 | fields := []struct { 1082 | Name string 1083 | ParentName string 1084 | DefaultValue string 1085 | EnvName string 1086 | FlagName string 1087 | Usage string 1088 | }{ 1089 | { 1090 | Name: "A", 1091 | EnvName: "one", 1092 | DefaultValue: "-1", 1093 | }, 1094 | { 1095 | Name: "B.C", 1096 | ParentName: "B", 1097 | FlagName: "two", 1098 | DefaultValue: "-1", 1099 | Usage: "pretty simple usage duh", 1100 | }, 1101 | { 1102 | Name: "B.D.E", 1103 | ParentName: "B.D", 1104 | EnvName: "three", 1105 | DefaultValue: "-1", 1106 | }, 1107 | } 1108 | 1109 | i := 0 1110 | 1111 | LoaderFor(&TestConfig{}, Config{NewParser: newParser}).WalkFields(func(f Field) bool { 1112 | wantFields := fields[i] 1113 | mustEqual(t, f.Name(), wantFields.Name) 1114 | mustEqual(t, f.Name(), wantFields.Name) 1115 | if parent, ok := f.Parent(); ok { 1116 | mustEqual(t, parent.Name(), wantFields.ParentName) 1117 | } 1118 | mustEqual(t, f.Tag("default"), wantFields.DefaultValue) 1119 | mustEqual(t, f.Tag("usage"), wantFields.Usage) 1120 | i++ 1121 | return true 1122 | }) 1123 | 1124 | mustEqual(t, i, 3) 1125 | 1126 | i = 0 1127 | LoaderFor(&TestConfig{}, Config{NewParser: newParser}).WalkFields(func(f Field) bool { 1128 | if i > 0 { 1129 | return false 1130 | } 1131 | if got := f.Tag("marco"); got != "polo" { 1132 | t.Fatalf("got %v, want %v", got, "polo") 1133 | } 1134 | i++ 1135 | return true 1136 | }) 1137 | if i != 1 { 1138 | t.Fatal() 1139 | } 1140 | } 1141 | 1142 | func TestDontFillFlagsIfDisabled(t *testing.T) { 1143 | loader := LoaderFor(&TestConfig{}, Config{ 1144 | NewParser: newParser, 1145 | SkipFlags: true, 1146 | Args: []string{}, 1147 | }) 1148 | failIfErr(t, loader.Load()) 1149 | 1150 | if flags := loader.Flags().NFlag(); flags != 0 { 1151 | t.Errorf("want empty, got %v", flags) 1152 | } 1153 | } 1154 | 1155 | func TestPassBadStructs(t *testing.T) { 1156 | f := func(cfg any) { 1157 | t.Helper() 1158 | 1159 | defer func() { 1160 | t.Helper() 1161 | if err := recover(); err == nil { 1162 | t.Fatal() 1163 | } 1164 | }() 1165 | 1166 | _ = LoaderFor(cfg, Config{ 1167 | NewParser: newParser, 1168 | }) 1169 | } 1170 | 1171 | f(nil) 1172 | f(map[string]string{}) 1173 | f([]string{}) 1174 | f([4]string{}) 1175 | f(func() {}) 1176 | 1177 | type S struct { 1178 | Foo int 1179 | } 1180 | f(S{}) 1181 | } 1182 | 1183 | func TestBadRequiredTag(t *testing.T) { 1184 | type TestConfig struct { 1185 | Field string `required:"boom"` 1186 | } 1187 | 1188 | f := func(cfg any) { 1189 | t.Helper() 1190 | 1191 | defer func() { 1192 | t.Helper() 1193 | if err := recover(); err == nil { 1194 | t.Fatal() 1195 | } 1196 | }() 1197 | 1198 | _ = LoaderFor(cfg, Config{ 1199 | NewParser: newParser, 1200 | }) 1201 | } 1202 | 1203 | f(&TestConfig{}) 1204 | } 1205 | 1206 | func TestMissingFieldWithRequiredTag(t *testing.T) { 1207 | cfg := struct { 1208 | Field1 string `required:"true"` 1209 | }{} 1210 | loader := LoaderFor(&cfg, Config{ 1211 | SkipFlags: true, 1212 | }) 1213 | 1214 | err := loader.Load() 1215 | want := "load config: fields required but not set: Field1" 1216 | 1217 | if have := err.Error(); have != want { 1218 | t.Fatalf("got %v, want %v", err, want) 1219 | } 1220 | } 1221 | 1222 | func TestMissingFieldsWithRequiredTag(t *testing.T) { 1223 | cfg := struct { 1224 | Field1 string `required:"true"` 1225 | Field2 string `required:"true"` 1226 | }{} 1227 | loader := LoaderFor(&cfg, Config{ 1228 | SkipFlags: true, 1229 | }) 1230 | 1231 | err := loader.Load() 1232 | want := "load config: fields required but not set: Field1,Field2" 1233 | 1234 | if have := err.Error(); have != want { 1235 | t.Fatalf("got %v, want %v", err, want) 1236 | } 1237 | } 1238 | 1239 | func int32Ptr(a int32) *int32 { 1240 | return &a 1241 | } 1242 | 1243 | type TestConfig struct { 1244 | Str string `default:"str-def"` 1245 | Bytes []byte `default:"bytes-def"` 1246 | Int *int32 `default:"123"` 1247 | HTTPPort int `default:"8080"` 1248 | Param int // no default tag, so default value 1249 | // ParamPtr *int // no default tag, so default value 1250 | Sub SubConfig 1251 | Anon struct { 1252 | IsAnon bool `default:"true"` 1253 | } 1254 | 1255 | StrSlice []string `default:"1,2,3" usage:"just pass strings"` 1256 | Slice []int `default:"1,2,3" usage:"just pass elements"` 1257 | Map1 map[string]int `default:"a:1,b:2,c:3"` 1258 | Map2 map[int]string `default:"1:a,2:b,3:c"` 1259 | 1260 | EmbeddedConfig 1261 | } 1262 | 1263 | type EmbeddedConfig struct { 1264 | Em string `default:"em-def" usage:"use... em...field."` 1265 | } 1266 | 1267 | type SubConfig struct { 1268 | Float float64 `default:"123.123"` 1269 | } 1270 | 1271 | type structConfig struct { 1272 | A string 1273 | C int 1274 | E float64 1275 | B []byte 1276 | I *int32 1277 | J *int64 1278 | Y structY 1279 | 1280 | AA structA `json:"A"` 1281 | StructM 1282 | 1283 | MM any `json:"MM"` 1284 | 1285 | P *structP `json:"P"` 1286 | } 1287 | 1288 | type structY struct { 1289 | X string 1290 | Z []int 1291 | A structD 1292 | } 1293 | 1294 | type structA struct { 1295 | X string `json:"x"` 1296 | BB structB `json:"B"` 1297 | } 1298 | 1299 | type structB struct { 1300 | CC structC `json:"C"` 1301 | DD []string `json:"D"` 1302 | } 1303 | 1304 | type structC struct { 1305 | MM string `json:"m"` 1306 | BB []byte `json:"b"` 1307 | } 1308 | 1309 | type structD struct { 1310 | I bool 1311 | } 1312 | 1313 | type StructM struct { 1314 | M string 1315 | } 1316 | 1317 | type structP struct { 1318 | P string `json:"P"` 1319 | } 1320 | 1321 | var testfile = &fstest.MapFile{Data: []byte(`{ 1322 | "a": "b", 1323 | "c": 10, 1324 | "e": 123.456, 1325 | "b": "abc", 1326 | "i": 42, 1327 | "j": 420, 1328 | 1329 | "y": { 1330 | "x": "y", 1331 | "z": [1, "2", "3"], 1332 | "a": { 1333 | "i": true 1334 | } 1335 | }, 1336 | 1337 | "A": { 1338 | "x": "y", 1339 | "B": { 1340 | "C": { 1341 | "m": "n", 1342 | "b": "boo" 1343 | }, 1344 | "D": ["x", "y", "z"] 1345 | } 1346 | }, 1347 | 1348 | "m": "n", 1349 | 1350 | "MM":["q", "w"], 1351 | 1352 | "P": { 1353 | "P": "r" 1354 | } 1355 | } 1356 | `)} 1357 | 1358 | var wantConfig = func() structConfig { 1359 | i := int32(42) 1360 | j := int64(420) 1361 | mInterface := make([]any, 2) 1362 | for iI, vI := range []string{"q", "w"} { 1363 | mInterface[iI] = vI 1364 | } 1365 | 1366 | return structConfig{ 1367 | A: "b", 1368 | C: 10, 1369 | E: 123.456, 1370 | B: []byte("abc"), 1371 | I: &i, 1372 | J: &j, 1373 | Y: structY{ 1374 | X: "y", 1375 | Z: []int{1, 2, 3}, 1376 | A: structD{ 1377 | I: true, 1378 | }, 1379 | }, 1380 | AA: structA{ 1381 | X: "y", 1382 | BB: structB{ 1383 | CC: structC{ 1384 | MM: "n", 1385 | BB: []byte("boo"), 1386 | }, 1387 | DD: []string{"x", "y", "z"}, 1388 | }, 1389 | }, 1390 | StructM: StructM{ 1391 | M: "n", 1392 | }, 1393 | MM: mInterface, 1394 | P: &structP{ 1395 | P: "r", 1396 | }, 1397 | } 1398 | }() 1399 | 1400 | type ConfigTest struct { 1401 | VCenter ConfigVCenter `json:"vcenter" env:"VCENTER"` 1402 | } 1403 | 1404 | type ConfigVCenter struct { 1405 | User string `json:"user" env:"USER"` 1406 | Password string `json:"password" env:"PASSWORD"` 1407 | Port string `json:"port" env:"PORT"` 1408 | Datacenters []ConfigVCenterDCRegion `json:"datacenters" env:"-"` 1409 | } 1410 | 1411 | type ConfigVCenterDCRegion struct { 1412 | Region string `json:"region"` 1413 | Addresses []ConfigVCenterDC `json:"addresses"` 1414 | } 1415 | 1416 | type ConfigVCenterDC struct { 1417 | Zone string `json:"zone"` 1418 | Address string `json:"address"` 1419 | Datacenter string `json:"datacenter"` 1420 | } 1421 | 1422 | func TestSliceStructs(t *testing.T) { 1423 | var cfg ConfigTest 1424 | loader := LoaderFor(&cfg, Config{ 1425 | NewParser: newParser, 1426 | SkipDefaults: true, 1427 | SkipEnv: true, 1428 | SkipFlags: true, 1429 | Files: []string{"testdata/complex.json"}, 1430 | }) 1431 | 1432 | failIfErr(t, loader.Load()) 1433 | 1434 | want := ConfigTest{ 1435 | VCenter: ConfigVCenter{ 1436 | User: "user-test", 1437 | Password: "pass-test", 1438 | Port: "8080", 1439 | Datacenters: []ConfigVCenterDCRegion{ 1440 | { 1441 | Region: "region-test", 1442 | Addresses: []ConfigVCenterDC{ 1443 | { 1444 | Zone: "zone-test", 1445 | Address: "address-test", 1446 | Datacenter: "datacenter-test", 1447 | }, 1448 | }, 1449 | }, 1450 | }, 1451 | }, 1452 | } 1453 | mustEqual(t, cfg, want) 1454 | } 1455 | 1456 | func TestJSONMap(t *testing.T) { 1457 | type TestConfig struct { 1458 | Options map[string]float64 1459 | } 1460 | var cfg TestConfig 1461 | 1462 | loader := LoaderFor(&cfg, Config{ 1463 | NewParser: newParser, 1464 | SkipDefaults: true, 1465 | SkipEnv: true, 1466 | SkipFlags: true, 1467 | Files: []string{"testdata/toy.json"}, 1468 | }) 1469 | 1470 | failIfErr(t, loader.Load()) 1471 | 1472 | want := TestConfig{ 1473 | Options: map[string]float64{ 1474 | "foo": 0.4, 1475 | "bar": 0.25, 1476 | }, 1477 | } 1478 | 1479 | mustEqual(t, cfg, want) 1480 | } 1481 | 1482 | func TestBad(t *testing.T) { 1483 | t.Skip("probably too picky") 1484 | 1485 | type TestConfig struct { 1486 | Params url.Values 1487 | } 1488 | var cfg TestConfig 1489 | t.Setenv("PARAMS", "foo:bar") 1490 | 1491 | p, err := url.ParseQuery("foo=bar") 1492 | if err != nil { 1493 | t.Fatal(err) 1494 | } 1495 | fmt.Printf("have: %+v\n", p) 1496 | 1497 | loader := LoaderFor(&cfg, Config{ 1498 | NewParser: newParser, 1499 | SkipFlags: true, 1500 | }) 1501 | failIfErr(t, loader.Load()) 1502 | 1503 | want := TestConfig{ 1504 | Params: p, 1505 | } 1506 | mustEqual(t, cfg, want) 1507 | } 1508 | 1509 | func TestFileConfigFlagDelim(t *testing.T) { 1510 | type TestConfig struct { 1511 | Options struct { 1512 | Foo float64 1513 | Bar float64 1514 | } 1515 | } 1516 | var cfg TestConfig 1517 | 1518 | loader := LoaderFor(&cfg, Config{ 1519 | NewParser: newParser, 1520 | SkipDefaults: true, 1521 | SkipEnv: true, 1522 | SkipFlags: true, 1523 | FlagDelimiter: "_", 1524 | Files: []string{"testdata/toy.json"}, 1525 | }) 1526 | 1527 | failIfErr(t, loader.Load()) 1528 | 1529 | want := TestConfig{Options: struct { 1530 | Foo float64 1531 | Bar float64 1532 | }{0.4, 0.25}} 1533 | 1534 | mustEqual(t, cfg, want) 1535 | } 1536 | 1537 | func TestSliceOfStructsWithSliceOfPrimitives(t *testing.T) { 1538 | type TestService struct { 1539 | Name string 1540 | Strings []string 1541 | Integers []int 1542 | Booleans []bool 1543 | } 1544 | 1545 | type TestConfig struct { 1546 | Services []TestService 1547 | } 1548 | var cfg TestConfig 1549 | loader := LoaderFor(&cfg, Config{ 1550 | SkipDefaults: true, 1551 | SkipEnv: true, 1552 | SkipFlags: true, 1553 | Files: []string{"testdata/slice-struct-primitive-slice.json"}, 1554 | }) 1555 | 1556 | failIfErr(t, loader.Load()) 1557 | 1558 | want := TestConfig{ 1559 | Services: []TestService{ 1560 | { 1561 | Name: "service1", 1562 | Strings: []string{"string1", "string2"}, 1563 | Integers: []int{1, 2}, 1564 | Booleans: []bool{true, false}, 1565 | }, 1566 | }, 1567 | } 1568 | mustEqual(t, cfg, want) 1569 | } 1570 | 1571 | func TestSliceOfDeepStructs(t *testing.T) { 1572 | type String string 1573 | 1574 | type NestedStruct struct{ Key int } 1575 | 1576 | type TestService struct { 1577 | Name String 1578 | Strings []String 1579 | Integers []int 1580 | Nullable *struct{} 1581 | Booleans []bool 1582 | Structs []*NestedStruct 1583 | } 1584 | 1585 | type TestConfig struct { 1586 | Services []*struct{ Nested TestService } 1587 | } 1588 | var cfg TestConfig 1589 | loader := LoaderFor(&cfg, Config{ 1590 | SkipDefaults: true, 1591 | SkipEnv: true, 1592 | SkipFlags: true, 1593 | Files: []string{"testdata/slice-deep-structs.json"}, 1594 | }) 1595 | 1596 | failIfErr(t, loader.Load()) 1597 | 1598 | want := TestConfig{ 1599 | Services: []*struct{ Nested TestService }{ 1600 | { 1601 | Nested: TestService{ 1602 | Name: "service1", 1603 | Strings: []String{"string1", "string2"}, 1604 | Integers: []int{1, 2}, 1605 | Nullable: nil, 1606 | Booleans: []bool{true, false}, 1607 | Structs: []*NestedStruct{ 1608 | {1}, 1609 | {2}, 1610 | nil, 1611 | {3}, 1612 | }, 1613 | }, 1614 | }, 1615 | nil, 1616 | }, 1617 | } 1618 | mustEqual(t, cfg, want) 1619 | } 1620 | 1621 | func failIfOk(tb testing.TB, err error) { 1622 | tb.Helper() 1623 | if err == nil { 1624 | tb.Fatal("must be non-nil") 1625 | } 1626 | } 1627 | 1628 | func failIfErr(tb testing.TB, err error) { 1629 | tb.Helper() 1630 | if err != nil { 1631 | tb.Fatal(err) 1632 | } 1633 | } 1634 | 1635 | func mustEqual(tb testing.TB, got, want any) { 1636 | tb.Helper() 1637 | if !reflect.DeepEqual(got, want) { 1638 | tb.Fatalf("\nhave %+v\nwant %+v", got, want) 1639 | } 1640 | } 1641 | -------------------------------------------------------------------------------- /aconfigdotenv/dotenv.go: -------------------------------------------------------------------------------- 1 | package aconfigdotenv 2 | 3 | import ( 4 | "io/fs" 5 | 6 | "github.com/joho/godotenv" 7 | ) 8 | 9 | // Decoder of DotENV files for aconfig. 10 | type Decoder struct { 11 | fsys fs.FS 12 | } 13 | 14 | // New .ENV decoder for aconfig. 15 | func New() *Decoder { return &Decoder{} } 16 | 17 | // Format of the decoder. 18 | func (d *Decoder) Format() string { 19 | return "env" 20 | } 21 | 22 | // DecodeFile implements aconfig.FileDecoder. 23 | func (d *Decoder) DecodeFile(filename string) (map[string]interface{}, error) { 24 | file, err := d.fsys.Open(filename) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | raw, err := godotenv.Parse(file) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | res := make(map[string]interface{}, len(raw)) 35 | for key, value := range raw { 36 | res[key] = value 37 | } 38 | return res, nil 39 | } 40 | 41 | // DecodeFile implements aconfig.FileDecoder. 42 | func (d *Decoder) Init(fsys fs.FS) { 43 | d.fsys = fsys 44 | } 45 | -------------------------------------------------------------------------------- /aconfigdotenv/dotenv_test.go: -------------------------------------------------------------------------------- 1 | package aconfigdotenv_test 2 | 3 | import ( 4 | "embed" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/cristalhq/aconfig" 10 | "github.com/cristalhq/aconfig/aconfigdotenv" 11 | ) 12 | 13 | //go:embed testdata 14 | var configEmbed embed.FS 15 | 16 | func TestDotEnvEmbed(t *testing.T) { 17 | var cfg struct { 18 | Foo string 19 | Bar string 20 | } 21 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 22 | SkipDefaults: true, 23 | SkipEnv: true, 24 | SkipFlags: true, 25 | FailOnFileNotFound: true, 26 | FileDecoders: map[string]aconfig.FileDecoder{ 27 | ".env": aconfigdotenv.New(), 28 | }, 29 | Files: []string{"testdata/config.env"}, 30 | FileSystem: configEmbed, 31 | }) 32 | 33 | if err := loader.Load(); err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | if cfg.Foo != "value1" { 38 | t.Fatalf("have: %v", cfg.Foo) 39 | } 40 | if cfg.Bar != "value2" { 41 | t.Fatalf("have: %v", cfg.Bar) 42 | } 43 | } 44 | 45 | func TestDotEnv(t *testing.T) { 46 | filepath := createTestFile(t) 47 | 48 | var cfg structConfig 49 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 50 | SkipDefaults: true, 51 | SkipEnv: true, 52 | SkipFlags: true, 53 | FileDecoders: map[string]aconfig.FileDecoder{ 54 | ".env": aconfigdotenv.New(), 55 | }, 56 | Files: []string{filepath}, 57 | }) 58 | 59 | if err := loader.Load(); err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | i := int32(42) 64 | j := int64(420) 65 | want := structConfig{ 66 | A: "b", 67 | C: 10, 68 | E: 123.456, 69 | B: []byte("abc"), 70 | I: &i, 71 | J: &j, 72 | Y: structY{ 73 | X: "y", 74 | Z: []string{"1", "2", "3"}, 75 | }, 76 | AA: structA{ 77 | X: "y", 78 | BB: structB{ 79 | CC: structC{ 80 | MM: "n", 81 | BB: []byte("boo"), 82 | }, 83 | DD: []string{"x", "y", "z"}, 84 | }, 85 | }, 86 | StructM: StructM{ 87 | M: "n", 88 | }, 89 | MI: "q,w", 90 | } 91 | 92 | if got := cfg; !reflect.DeepEqual(want, got) { 93 | t.Fatalf("want %v, got %v", want, got) 94 | } 95 | } 96 | 97 | func createTestFile(t *testing.T) string { 98 | t.Helper() 99 | dir := t.TempDir() 100 | t.Cleanup(func() { 101 | os.RemoveAll(dir) 102 | }) 103 | 104 | filepath := dir + "/testfile.env" 105 | 106 | f, err := os.Create(filepath) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | defer f.Close() 111 | _, err = f.WriteString(testfileContent) 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | return filepath 116 | } 117 | 118 | type structConfig struct { 119 | A string 120 | C int 121 | E float64 122 | B []byte 123 | I *int32 124 | J *int64 125 | Y structY 126 | 127 | AA structA `env:"A"` 128 | StructM 129 | MI interface{} `env:"MI"` 130 | } 131 | 132 | type structY struct { 133 | X string 134 | Z []string 135 | A struct { 136 | I bool 137 | } 138 | } 139 | 140 | type structA struct { 141 | X string `env:"x"` 142 | BB structB `env:"B"` 143 | } 144 | 145 | type structB struct { 146 | CC structC `env:"C"` 147 | DD []string `env:"D"` 148 | } 149 | 150 | type structC struct { 151 | MM string `env:"m"` 152 | BB []byte `env:"b"` 153 | } 154 | 155 | type StructM struct { 156 | M string 157 | } 158 | 159 | const testfileContent = ` 160 | A=b 161 | C=10 162 | E=123.456 163 | B=abc 164 | I=42 165 | J=420 166 | 167 | Y_X=y 168 | Y_Z=1,2,3 169 | 170 | A_x=y 171 | A_B_C_m=n 172 | A_B_C_b=boo 173 | A_B_D=x,y,z 174 | 175 | M=n 176 | MI=q,w 177 | ` 178 | -------------------------------------------------------------------------------- /aconfigdotenv/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cristalhq/aconfig/aconfigdotenv 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/cristalhq/aconfig v0.17.0 7 | github.com/joho/godotenv v1.4.0 8 | ) 9 | -------------------------------------------------------------------------------- /aconfigdotenv/go.sum: -------------------------------------------------------------------------------- 1 | github.com/cristalhq/aconfig v0.17.0 h1:VYqg0YOM5yUEx0KH/VwUYF2e/PNI7dcUE66y+xEx73s= 2 | github.com/cristalhq/aconfig v0.17.0/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E= 3 | github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= 4 | github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 5 | -------------------------------------------------------------------------------- /aconfigdotenv/testdata/config.env: -------------------------------------------------------------------------------- 1 | FOO=value1 2 | BAR=value2 3 | -------------------------------------------------------------------------------- /aconfighcl/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cristalhq/aconfig/aconfighcl 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/cristalhq/aconfig v0.17.0 7 | github.com/hashicorp/hcl v1.0.0 8 | ) 9 | -------------------------------------------------------------------------------- /aconfighcl/go.sum: -------------------------------------------------------------------------------- 1 | github.com/cristalhq/aconfig v0.16.8 h1:lg8i0XHgfhvsnjNM5q/ou6jIHDRXlbBybjRP9t2fWuw= 2 | github.com/cristalhq/aconfig v0.16.8/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E= 3 | github.com/cristalhq/aconfig v0.17.0 h1:VYqg0YOM5yUEx0KH/VwUYF2e/PNI7dcUE66y+xEx73s= 4 | github.com/cristalhq/aconfig v0.17.0/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 8 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 9 | -------------------------------------------------------------------------------- /aconfighcl/hcl.go: -------------------------------------------------------------------------------- 1 | package aconfighcl 2 | 3 | import ( 4 | "io/fs" 5 | 6 | "github.com/hashicorp/hcl" 7 | ) 8 | 9 | // Decoder of HCL files for aconfig. 10 | type Decoder struct { 11 | fsys fs.FS 12 | } 13 | 14 | // New HCL decoder for aconfig. 15 | func New() *Decoder { return &Decoder{} } 16 | 17 | // Format of the decoder. 18 | func (d *Decoder) Format() string { 19 | return "hcl" 20 | } 21 | 22 | // DecodeFile implements aconfig.FileDecoder. 23 | func (d *Decoder) DecodeFile(filename string) (map[string]interface{}, error) { 24 | b, err := fs.ReadFile(d.fsys, filename) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | f, err := hcl.ParseBytes(b) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | var raw map[string]interface{} 35 | if err := hcl.DecodeObject(&raw, f); err != nil { 36 | return nil, err 37 | } 38 | return raw, nil 39 | } 40 | 41 | // DecodeFile implements aconfig.FileDecoder. 42 | func (d *Decoder) Init(fsys fs.FS) { 43 | d.fsys = fsys 44 | } 45 | -------------------------------------------------------------------------------- /aconfighcl/hcl_test.go: -------------------------------------------------------------------------------- 1 | package aconfighcl_test 2 | 3 | import ( 4 | "embed" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/cristalhq/aconfig" 10 | "github.com/cristalhq/aconfig/aconfighcl" 11 | ) 12 | 13 | //go:embed testdata 14 | var configEmbed embed.FS 15 | 16 | func TestHCLEmbed(t *testing.T) { 17 | var cfg struct { 18 | Foo string 19 | Bar string 20 | } 21 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 22 | SkipDefaults: true, 23 | SkipEnv: true, 24 | SkipFlags: true, 25 | FailOnFileNotFound: true, 26 | FileDecoders: map[string]aconfig.FileDecoder{ 27 | ".hcl": aconfighcl.New(), 28 | }, 29 | Files: []string{"testdata/config.hcl"}, 30 | FileSystem: configEmbed, 31 | }) 32 | 33 | if err := loader.Load(); err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | if cfg.Foo != "value1" { 38 | t.Fatalf("have: %v", cfg.Foo) 39 | } 40 | if cfg.Bar != "value2" { 41 | t.Fatalf("have: %v", cfg.Bar) 42 | } 43 | } 44 | func TestHCL(t *testing.T) { 45 | filepath := createTestFile(t) 46 | 47 | var cfg structConfig 48 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 49 | SkipDefaults: true, 50 | SkipEnv: true, 51 | SkipFlags: true, 52 | FileDecoders: map[string]aconfig.FileDecoder{ 53 | ".hcl": aconfighcl.New(), 54 | }, 55 | Files: []string{filepath}, 56 | }) 57 | 58 | if err := loader.Load(); err != nil { 59 | t.Fatal(err) 60 | } 61 | 62 | i := int32(42) 63 | j := int64(420) 64 | mInterface := make([]interface{}, 2) 65 | for iI, vI := range []string{"q", "w"} { 66 | mInterface[iI] = vI 67 | } 68 | want := structConfig{ 69 | A: "b", 70 | C: 10, 71 | E: 123.456, 72 | B: []byte("abc"), 73 | I: &i, 74 | J: &j, 75 | Y: structY{ 76 | X: "y", 77 | Z: []string{"1", "2", "3"}, 78 | A: structD{ 79 | I: true, 80 | }, 81 | }, 82 | AA: structA{ 83 | X: "y", 84 | BB: structB{ 85 | CC: structC{ 86 | MM: "n", 87 | BB: []byte("boo"), 88 | }, 89 | DD: []string{"x", "y", "z"}, 90 | }, 91 | }, 92 | StructM: StructM{ 93 | M: "n", 94 | }, 95 | MI: mInterface, 96 | } 97 | 98 | if got := cfg; !reflect.DeepEqual(want, got) { 99 | t.Fatalf("want %v, got %v", want, got) 100 | } 101 | } 102 | 103 | func createTestFile(t *testing.T) string { 104 | t.Helper() 105 | dir := t.TempDir() 106 | t.Cleanup(func() { 107 | os.RemoveAll(dir) 108 | }) 109 | 110 | filepath := dir + "/testfile.hcl" 111 | 112 | f, err := os.Create(filepath) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | defer f.Close() 117 | _, err = f.WriteString(testfileContent) 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | return filepath 122 | } 123 | 124 | type structConfig struct { 125 | A string `hcl:"a"` 126 | C int `hcl:"c"` 127 | E float64 `hcl:"e"` 128 | B []byte `hcl:"b"` 129 | I *int32 `hcl:"i"` 130 | J *int64 `hcl:"j"` 131 | Y structY `hcl:"y"` 132 | 133 | AA structA `hcl:"A"` 134 | StructM 135 | MI interface{} `hcl:"MI"` 136 | } 137 | 138 | type structY struct { 139 | X string `hcl:"x"` 140 | Z []string `hcl:"z"` 141 | A structD `hcl:"A"` 142 | } 143 | 144 | type structA struct { 145 | X string `hcl:"x"` 146 | BB structB `hcl:"B"` 147 | } 148 | 149 | type structB struct { 150 | CC structC `hcl:"C"` 151 | DD []string `hcl:"D"` 152 | } 153 | 154 | type structC struct { 155 | MM string `hcl:"m"` 156 | BB []byte `hcl:"b"` 157 | } 158 | 159 | type structD struct { 160 | I bool `hcl:"i"` 161 | } 162 | 163 | type StructM struct { 164 | M string `hcl:"M"` 165 | } 166 | 167 | const testfileContent = ` 168 | "a" = "b" 169 | "c" = 10 170 | "e" = 123.456 171 | "b" = "abc" 172 | "i" = 42 173 | "j" = 420 174 | 175 | "y" = { 176 | "x" = "y" 177 | "z" = ["1", "2", "3"] 178 | "A" = { 179 | "i" = true 180 | } 181 | } 182 | "A" = { 183 | "x" = "y" 184 | "B" = { 185 | "C" = { 186 | "m" = "n" 187 | "b" = "boo" 188 | } 189 | "D" = ["x", "y", "z"] 190 | } 191 | } 192 | 193 | "M" = "n" 194 | "MI" = ["q", "w"] 195 | ` 196 | -------------------------------------------------------------------------------- /aconfighcl/testdata/config.hcl: -------------------------------------------------------------------------------- 1 | "foo" = "value1" 2 | "bar" = "value2" 3 | -------------------------------------------------------------------------------- /aconfigtoml/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cristalhq/aconfig/aconfigtoml 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/cristalhq/aconfig v0.18.5 8 | ) 9 | -------------------------------------------------------------------------------- /aconfigtoml/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/cristalhq/aconfig v0.18.5 h1:QqXH/Gy2c4QUQJTV2BN8UAuL/rqZ3IwhvxeC8OgzquA= 4 | github.com/cristalhq/aconfig v0.18.5/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E= 5 | -------------------------------------------------------------------------------- /aconfigtoml/testdata/config.toml: -------------------------------------------------------------------------------- 1 | foo = "value1" 2 | bar = "value2" 3 | 4 | [outter] 5 | [outter.inner] 6 | [[outter.inner.t1]] 7 | a = "a" 8 | b = "b" 9 | [[outter.inner.t1]] 10 | a = "c" 11 | b = "d" -------------------------------------------------------------------------------- /aconfigtoml/toml.go: -------------------------------------------------------------------------------- 1 | package aconfigtoml 2 | 3 | import ( 4 | "io/fs" 5 | 6 | "github.com/BurntSushi/toml" 7 | ) 8 | 9 | // Decoder of TOML files for aconfig. 10 | type Decoder struct { 11 | fsys fs.FS 12 | } 13 | 14 | // New TOML decoder for aconfig. 15 | func New() *Decoder { return &Decoder{} } 16 | 17 | // Format of the decoder. 18 | func (d *Decoder) Format() string { 19 | return "toml" 20 | } 21 | 22 | // DecodeFile implements aconfig.FileDecoder. 23 | func (d *Decoder) DecodeFile(filename string) (map[string]interface{}, error) { 24 | f, err := d.fsys.Open(filename) 25 | if err != nil { 26 | return nil, err 27 | } 28 | defer f.Close() 29 | 30 | var raw map[string]interface{} 31 | if _, err := toml.DecodeReader(f, &raw); err != nil { 32 | return nil, err 33 | } 34 | return raw, nil 35 | } 36 | 37 | // DecodeFile implements aconfig.FileDecoder. 38 | func (d *Decoder) Init(fsys fs.FS) { 39 | d.fsys = fsys 40 | } 41 | -------------------------------------------------------------------------------- /aconfigtoml/toml_test.go: -------------------------------------------------------------------------------- 1 | package aconfigtoml_test 2 | 3 | import ( 4 | "embed" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/cristalhq/aconfig" 10 | "github.com/cristalhq/aconfig/aconfigtoml" 11 | ) 12 | 13 | //go:embed testdata 14 | var configEmbed embed.FS 15 | 16 | func TestTOMLEmbed(t *testing.T) { 17 | var cfg struct { 18 | Foo string 19 | Bar string 20 | Outter map[string]map[string][]struct { 21 | A string 22 | B string 23 | } 24 | } 25 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 26 | SkipDefaults: true, 27 | SkipEnv: true, 28 | SkipFlags: true, 29 | FailOnFileNotFound: true, 30 | FileDecoders: map[string]aconfig.FileDecoder{ 31 | ".toml": aconfigtoml.New(), 32 | }, 33 | Files: []string{"testdata/config.toml"}, 34 | FileSystem: configEmbed, 35 | }) 36 | 37 | if err := loader.Load(); err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | if cfg.Foo != "value1" { 42 | t.Fatalf("have: %v", cfg.Foo) 43 | } 44 | if cfg.Bar != "value2" { 45 | t.Fatalf("have: %v", cfg.Bar) 46 | } 47 | 48 | if cfg.Outter["inner"]["t1"][0].A != "a" { 49 | t.Fatalf("have: %v", cfg.Outter["inner"]["t1"][0].A) 50 | } 51 | } 52 | 53 | func TestTOML(t *testing.T) { 54 | filepath := createTestFile(t) 55 | 56 | var cfg structConfig 57 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 58 | SkipDefaults: true, 59 | SkipEnv: true, 60 | SkipFlags: true, 61 | FileDecoders: map[string]aconfig.FileDecoder{ 62 | ".toml": aconfigtoml.New(), 63 | }, 64 | Files: []string{filepath}, 65 | }) 66 | 67 | if err := loader.Load(); err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | i := int32(42) 72 | j := int64(420) 73 | mInterface := make([]interface{}, 2) 74 | for iI, vI := range []string{"q", "w"} { 75 | mInterface[iI] = vI 76 | } 77 | want := structConfig{ 78 | A: "b", 79 | C: 10, 80 | E: 123.456, 81 | B: []byte("abc"), 82 | I: &i, 83 | J: &j, 84 | Y: structY{ 85 | X: "y", 86 | Z: []string{"1", "2", "3"}, 87 | A: structD{ 88 | I: true, 89 | }, 90 | }, 91 | AA: structA{ 92 | X: "y", 93 | BB: structB{ 94 | CC: structC{ 95 | MM: "n", 96 | BB: []byte("boo"), 97 | }, 98 | DD: []string{"x", "y", "z"}, 99 | }, 100 | }, 101 | StructM: StructM{ 102 | M: "n", 103 | }, 104 | MI: mInterface, 105 | } 106 | 107 | if got := cfg; !reflect.DeepEqual(want, got) { 108 | t.Fatalf("want %v, got %v", want, got) 109 | } 110 | } 111 | 112 | func createTestFile(t *testing.T) string { 113 | t.Helper() 114 | dir := t.TempDir() 115 | t.Cleanup(func() { 116 | os.RemoveAll(dir) 117 | }) 118 | 119 | filepath := dir + "/testfile.toml" 120 | 121 | f, err := os.Create(filepath) 122 | if err != nil { 123 | t.Fatal(err) 124 | } 125 | defer f.Close() 126 | _, err = f.WriteString(testfileContent) 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | return filepath 131 | } 132 | 133 | type structConfig struct { 134 | A string 135 | C int 136 | E float64 137 | B []byte 138 | I *int32 139 | J *int64 140 | Y structY 141 | 142 | AA structA `toml:"A"` 143 | StructM 144 | MI interface{} `toml:"MI"` 145 | } 146 | 147 | type structY struct { 148 | X string 149 | Z []string 150 | A structD 151 | } 152 | 153 | type structA struct { 154 | X string `toml:"x"` 155 | BB structB `toml:"B"` 156 | } 157 | 158 | type structB struct { 159 | CC structC `toml:"C"` 160 | DD []string `toml:"D"` 161 | } 162 | 163 | type structC struct { 164 | MM string `toml:"m"` 165 | BB []byte `toml:"b"` 166 | } 167 | 168 | type structD struct { 169 | I bool 170 | } 171 | 172 | type StructM struct { 173 | M string 174 | } 175 | 176 | const testfileContent = ` 177 | a = "b" 178 | c = 10 179 | e = 123.456 180 | b = "abc" 181 | i = 42 182 | j = 420 183 | m = "n" 184 | MI = ["q", "w"] 185 | 186 | [y] 187 | x = "y" 188 | z = [ 1, 2, 3 ] 189 | [y.a] 190 | i = true 191 | 192 | [A] 193 | x = "y" 194 | 195 | [A.B] 196 | D = ["x", "y", "z"] 197 | 198 | [A.B.C] 199 | m = "n" 200 | b = "boo" 201 | ` 202 | -------------------------------------------------------------------------------- /aconfigyaml/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cristalhq/aconfig/aconfigyaml 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/cristalhq/aconfig v0.17.0 7 | gopkg.in/yaml.v3 v3.0.1 8 | ) 9 | -------------------------------------------------------------------------------- /aconfigyaml/go.sum: -------------------------------------------------------------------------------- 1 | github.com/cristalhq/aconfig v0.17.0 h1:VYqg0YOM5yUEx0KH/VwUYF2e/PNI7dcUE66y+xEx73s= 2 | github.com/cristalhq/aconfig v0.17.0/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E= 3 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 4 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 5 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 6 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 7 | -------------------------------------------------------------------------------- /aconfigyaml/res.yaml: -------------------------------------------------------------------------------- 1 | resources_a: 2 | 3 | resources_b: -------------------------------------------------------------------------------- /aconfigyaml/testdata/config.yaml: -------------------------------------------------------------------------------- 1 | foo: value1 2 | bar: value2 3 | -------------------------------------------------------------------------------- /aconfigyaml/yaml.go: -------------------------------------------------------------------------------- 1 | package aconfigyaml 2 | 3 | import ( 4 | "io/fs" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | // Decoder of YAML files for aconfig. 10 | type Decoder struct { 11 | fsys fs.FS 12 | } 13 | 14 | // New YAML decoder for aconfig. 15 | func New() *Decoder { return &Decoder{} } 16 | 17 | // Format of the decoder. 18 | func (d *Decoder) Format() string { 19 | return "yaml" 20 | } 21 | 22 | // DecodeFile implements aconfig.FileDecoder. 23 | func (d *Decoder) DecodeFile(filename string) (map[string]interface{}, error) { 24 | f, err := d.fsys.Open(filename) 25 | if err != nil { 26 | return nil, err 27 | } 28 | defer f.Close() 29 | 30 | var raw map[string]interface{} 31 | if err := yaml.NewDecoder(f).Decode(&raw); err != nil { 32 | return nil, err 33 | } 34 | return raw, nil 35 | } 36 | 37 | // DecodeFile implements aconfig.FileDecoder. 38 | func (d *Decoder) Init(fsys fs.FS) { 39 | d.fsys = fsys 40 | } 41 | -------------------------------------------------------------------------------- /aconfigyaml/yaml_test.go: -------------------------------------------------------------------------------- 1 | package aconfigyaml_test 2 | 3 | import ( 4 | "embed" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/cristalhq/aconfig" 10 | "github.com/cristalhq/aconfig/aconfigyaml" 11 | ) 12 | 13 | //go:embed testdata 14 | var configEmbed embed.FS 15 | 16 | func TestYAMLEmbed(t *testing.T) { 17 | var cfg struct { 18 | Foo string 19 | Bar string 20 | } 21 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 22 | SkipDefaults: true, 23 | SkipEnv: true, 24 | SkipFlags: true, 25 | FailOnFileNotFound: true, 26 | FileDecoders: map[string]aconfig.FileDecoder{ 27 | ".yaml": aconfigyaml.New(), 28 | }, 29 | Files: []string{"testdata/config.yaml"}, 30 | FileSystem: configEmbed, 31 | }) 32 | 33 | if err := loader.Load(); err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | if cfg.Foo != "value1" { 38 | t.Fatalf("have: %v", cfg.Foo) 39 | } 40 | if cfg.Bar != "value2" { 41 | t.Fatalf("have: %v", cfg.Bar) 42 | } 43 | } 44 | 45 | func TestYAML(t *testing.T) { 46 | filepath := createTestFile(t) 47 | 48 | var cfg structConfig 49 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 50 | SkipDefaults: true, 51 | SkipEnv: true, 52 | SkipFlags: true, 53 | FileDecoders: map[string]aconfig.FileDecoder{ 54 | ".yaml": aconfigyaml.New(), 55 | }, 56 | Files: []string{filepath}, 57 | }) 58 | 59 | if err := loader.Load(); err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | i := int32(42) 64 | j := int64(420) 65 | mInterface := make([]interface{}, 2) 66 | for iI, vI := range []string{"q", "w"} { 67 | mInterface[iI] = vI 68 | } 69 | want := structConfig{ 70 | A: "b", 71 | C: 10, 72 | E: 123.456, 73 | B: []byte("abc"), 74 | I: &i, 75 | J: &j, 76 | Y: structY{ 77 | X: "y", 78 | Z: []string{"1", "2", "3"}, 79 | A: structD{ 80 | I: true, 81 | }, 82 | }, 83 | AA: structA{ 84 | X: "y", 85 | BB: structB{ 86 | CC: structC{ 87 | MM: "n", 88 | BB: []byte("boo"), 89 | }, 90 | DD: []string{"x", "y", "z"}, 91 | }, 92 | }, 93 | StructM: StructM{ 94 | M: "n", 95 | }, 96 | MI: mInterface, 97 | } 98 | 99 | if got := cfg; !reflect.DeepEqual(want, got) { 100 | t.Fatalf("want %v, got %v", want, got) 101 | } 102 | } 103 | 104 | func TestLoadResources(t *testing.T) { 105 | type ResourceA struct { 106 | Field string `yaml:"field"` 107 | } 108 | type ResourceB struct { 109 | Field int `yaml:"field"` 110 | } 111 | type TestConfig struct { 112 | ResourcesA []ResourceA `yaml:"resources_a"` 113 | ResourcesB []ResourceB `yaml:"resources_b"` 114 | } 115 | 116 | var cfg TestConfig 117 | 118 | resourcesLoader := aconfig.LoaderFor(&cfg, 119 | aconfig.Config{ 120 | SkipFlags: true, 121 | Files: []string{"res.yaml"}, 122 | FailOnFileNotFound: true, 123 | FileDecoders: map[string]aconfig.FileDecoder{ 124 | ".yaml": aconfigyaml.New(), 125 | }, 126 | }) 127 | if err := resourcesLoader.Load(); err != nil { 128 | t.Errorf("failed to load resources configurations [err=%s]", err) 129 | } 130 | } 131 | 132 | func createTestFile(t *testing.T) string { 133 | t.Helper() 134 | dir := t.TempDir() 135 | t.Cleanup(func() { 136 | os.RemoveAll(dir) 137 | }) 138 | 139 | filepath := dir + "/testfile.yaml" 140 | 141 | f, err := os.Create(filepath) 142 | if err != nil { 143 | t.Fatal(err) 144 | } 145 | defer f.Close() 146 | _, err = f.WriteString(testfileContent) 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | return filepath 151 | } 152 | 153 | type structConfig struct { 154 | A string 155 | C int 156 | E float64 157 | B []byte 158 | I *int32 159 | J *int64 160 | Y structY 161 | 162 | AA structA `yaml:"A"` 163 | StructM 164 | MI interface{} `yaml:"MI"` 165 | } 166 | 167 | type structY struct { 168 | X string 169 | Z []string 170 | A structD 171 | } 172 | 173 | type structA struct { 174 | X string `yaml:"x"` 175 | BB structB `yaml:"B"` 176 | } 177 | 178 | type structB struct { 179 | CC structC `yaml:"C"` 180 | DD []string `yaml:"D"` 181 | } 182 | 183 | type structC struct { 184 | MM string `yaml:"m"` 185 | BB []byte `yaml:"b"` 186 | } 187 | 188 | type structD struct { 189 | I bool 190 | } 191 | 192 | type StructM struct { 193 | M string 194 | } 195 | 196 | const testfileContent = ` 197 | a: "b" 198 | c: 10 199 | e: 123.456 200 | b: "abc" 201 | i: 42 202 | j: 420 203 | 204 | y: 205 | x: "y" 206 | z: ["1", "2", "3"] 207 | a: 208 | "i": true 209 | 210 | A: 211 | x: "y" 212 | B: 213 | C: 214 | m: "n" 215 | b: "boo" 216 | D: ["x", "y", "z"] 217 | 218 | m: "n" 219 | 220 | MI: ["q", "w"] 221 | ` 222 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package aconfig provides simple but still powerful config loader. 2 | // 3 | // It can read configuration from different sources, like defaults, files, environment variables, console flag parameters. 4 | // 5 | // Defaults are defined in structure tags (`default` tag). For files JSON, YAML, TOML and .Env are supported. 6 | // 7 | // Environment variables and flag parameters can have an optional prefix to separate them from other entries. 8 | // 9 | // Also, aconfig is dependency-free, file decoders are used as separate modules (submodules to be exact) and are added to your go.mod only when used. 10 | // 11 | // Loader configuration (`Config` type) has different ways to configure loader, to skip some sources, define prefixes, fail on unknown params. 12 | package aconfig 13 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package aconfig_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/cristalhq/aconfig" 9 | ) 10 | 11 | type MyConfig struct { 12 | HTTPPort int `default:"1111" usage:"just a number"` 13 | Auth struct { 14 | User string `default:"def-user" usage:"your user"` 15 | Pass string `default:"def-pass" usage:"make it strong"` 16 | } 17 | } 18 | 19 | func Example_simpleUsage() { 20 | var cfg MyConfig 21 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 22 | SkipDefaults: true, 23 | SkipFiles: true, 24 | SkipEnv: true, 25 | SkipFlags: true, 26 | Files: []string{"/var/opt/myapp/config.json"}, 27 | EnvPrefix: "APP", 28 | FlagPrefix: "app", 29 | }) 30 | if err := loader.Load(); err != nil { 31 | log.Panic(err) 32 | } 33 | 34 | fmt.Printf("HTTPPort: %v\n", cfg.HTTPPort) 35 | fmt.Printf("Auth.User: %q\n", cfg.Auth.User) 36 | fmt.Printf("Auth.Pass: %q\n", cfg.Auth.Pass) 37 | 38 | // Output: 39 | // 40 | // HTTPPort: 0 41 | // Auth.User: "" 42 | // Auth.Pass: "" 43 | } 44 | 45 | func Example_walkFields() { 46 | var cfg MyConfig 47 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 48 | SkipFiles: true, 49 | SkipEnv: true, 50 | SkipFlags: true, 51 | }) 52 | loader.WalkFields(func(f aconfig.Field) bool { 53 | fmt.Printf("%v: %q %q %q %q\n", f.Name(), f.Tag("env"), f.Tag("flag"), f.Tag("default"), f.Tag("usage")) 54 | return true 55 | }) 56 | 57 | // Output: 58 | // HTTPPort: "HTTP_PORT" "http_port" "1111" "just a number" 59 | // Auth.User: "USER" "user" "def-user" "your user" 60 | // Auth.Pass: "PASS" "pass" "def-pass" "make it strong" 61 | } 62 | 63 | // Just load defaults from struct definition. 64 | func Example_defaults() { 65 | var cfg MyConfig 66 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 67 | SkipFiles: true, 68 | SkipEnv: true, 69 | SkipFlags: true, 70 | }) 71 | if err := loader.Load(); err != nil { 72 | log.Panic(err) 73 | } 74 | 75 | fmt.Printf("HTTPPort: %v\n", cfg.HTTPPort) 76 | fmt.Printf("Auth.User: %v\n", cfg.Auth.User) 77 | fmt.Printf("Auth.Pass: %v\n", cfg.Auth.Pass) 78 | 79 | // Output: 80 | // 81 | // HTTPPort: 1111 82 | // Auth.User: def-user 83 | // Auth.Pass: def-pass 84 | } 85 | 86 | // Load defaults from struct defunition and overwrite with a file. 87 | func Example_file() { 88 | var cfg MyConfig 89 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 90 | SkipEnv: true, 91 | SkipFlags: true, 92 | Files: []string{"testdata/example_config.json"}, 93 | }) 94 | if err := loader.Load(); err != nil { 95 | log.Panic(err) 96 | } 97 | 98 | fmt.Printf("HTTPPort: %v\n", cfg.HTTPPort) 99 | fmt.Printf("Auth.User: %v\n", cfg.Auth.User) 100 | fmt.Printf("Auth.Pass: %v\n", cfg.Auth.Pass) 101 | 102 | // Output: 103 | // 104 | // HTTPPort: 2222 105 | // Auth.User: json-user 106 | // Auth.Pass: json-pass 107 | } 108 | 109 | // Load defaults from struct definition and overwrite with a file. 110 | // And then overwrite with environment variables. 111 | func Example_env() { 112 | os.Setenv("EXAMPLE_HTTP_PORT", "3333") 113 | os.Setenv("EXAMPLE_AUTH_USER", "env-user") 114 | os.Setenv("EXAMPLE_AUTH_PASS", "env-pass") 115 | defer os.Clearenv() 116 | 117 | var cfg MyConfig 118 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 119 | SkipFlags: true, 120 | EnvPrefix: "EXAMPLE", 121 | Files: []string{"testdata/example_config.json"}, 122 | }) 123 | if err := loader.Load(); err != nil { 124 | log.Panic(err) 125 | } 126 | 127 | fmt.Printf("HTTPPort: %v\n", cfg.HTTPPort) 128 | fmt.Printf("Auth.User: %v\n", cfg.Auth.User) 129 | fmt.Printf("Auth.Pass: %v\n", cfg.Auth.Pass) 130 | 131 | // Output: 132 | // 133 | // HTTPPort: 3333 134 | // Auth.User: env-user 135 | // Auth.Pass: env-pass 136 | } 137 | 138 | // Load defaults from struct definition and overwrite with a file. 139 | // And then overwrite with environment variables. 140 | // Finally read command line flags. 141 | func Example_flag() { 142 | var cfg MyConfig 143 | loader := aconfig.LoaderFor(&cfg, aconfig.Config{ 144 | FlagPrefix: "ex", 145 | Files: []string{"testdata/example_config.json"}, 146 | }) 147 | 148 | flags := loader.Flags() // <- IMPORTANT: use this to define your non-config flags 149 | flags.String("my.other.port", "1234", "debug port") 150 | 151 | // IMPORTANT: next statement is made only to hack flag params 152 | // to make test example work 153 | // feel free to remove it completely during copy-paste :) 154 | os.Args = append([]string{}, os.Args[0], 155 | "-ex.http_port=4444", 156 | "-ex.auth.user=flag-user", 157 | "-ex.auth.pass=flag-pass", 158 | ) 159 | if err := flags.Parse(os.Args[1:]); err != nil { 160 | log.Panic(err) 161 | } 162 | 163 | if err := loader.Load(); err != nil { 164 | log.Panic(err) 165 | } 166 | 167 | fmt.Printf("HTTPPort: %v\n", cfg.HTTPPort) 168 | fmt.Printf("Auth.User: %v\n", cfg.Auth.User) 169 | fmt.Printf("Auth.Pass: %v\n", cfg.Auth.Pass) 170 | 171 | // Output: 172 | // 173 | // HTTPPort: 4444 174 | // Auth.User: flag-user 175 | // Auth.Pass: flag-pass 176 | } 177 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cristalhq/aconfig 2 | 3 | go 1.18 4 | 5 | require github.com/mitchellh/mapstructure v1.5.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 2 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 3 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package aconfig 2 | 3 | import ( 4 | "encoding" 5 | "flag" 6 | "fmt" 7 | "reflect" 8 | "strings" 9 | "time" 10 | 11 | "github.com/mitchellh/mapstructure" 12 | ) 13 | 14 | type structParser struct { 15 | cfg Config 16 | fields map[string]any 17 | flagSet *flag.FlagSet 18 | envNames map[string]struct{} 19 | flagNames map[string]struct{} 20 | } 21 | 22 | func newStructParser(cfg Config) *structParser { 23 | return &structParser{ 24 | cfg: cfg, 25 | flagSet: flag.NewFlagSet(cfg.FlagPrefix, flag.ContinueOnError), 26 | envNames: map[string]struct{}{}, 27 | flagNames: map[string]struct{}{}, 28 | } 29 | } 30 | 31 | type parsedField struct { 32 | name string 33 | namefull string 34 | value any 35 | defaultValue any 36 | parent *parsedField 37 | childs map[string]any 38 | tags map[string]string 39 | hasChilds bool 40 | isRequired bool 41 | } 42 | 43 | func (pf *parsedField) String() string { 44 | if pf == nil { 45 | return "" 46 | } 47 | return fmt.Sprintf("%+v", *pf) 48 | } 49 | 50 | func (sp *structParser) newParseField(parent *parsedField, field reflect.StructField) (*parsedField, error) { 51 | requiredTag := field.Tag.Get("required") 52 | if requiredTag != "" && requiredTag != "true" { 53 | panic(fmt.Sprintf("aconfig: value for 'required' tag can be only 'true' got: %q", requiredTag)) 54 | } 55 | 56 | name := field.Tag.Get("name") 57 | if name == "" { 58 | name = field.Name 59 | } 60 | 61 | newName := strings.ToLower(strings.Join(splitNameByWords(name), "_")) 62 | 63 | env := field.Tag.Get("env") 64 | if env == "" { 65 | env = strings.ToUpper(newName) 66 | } 67 | 68 | flag := field.Tag.Get("flag") 69 | if flag == "" { 70 | flag = newName 71 | } 72 | 73 | var parentName, parentEnv, parentFlag string 74 | if parent != nil { 75 | parentName = parent.namefull + "|" 76 | 77 | for p := parent; p != nil; p = p.parent { 78 | parentEnv = p.tags["env_name"] 79 | if parentEnv != "-" { 80 | break 81 | } 82 | } 83 | for p := parent; p != nil; p = p.parent { 84 | parentFlag = p.tags["flag_name"] 85 | if parentFlag != "-" { 86 | break 87 | } 88 | } 89 | 90 | parentEnv += sp.cfg.envDelimiter 91 | parentFlag += sp.cfg.FlagDelimiter 92 | } 93 | 94 | pfield := &parsedField{ 95 | name: name, 96 | namefull: parentName + name, 97 | parent: parent, 98 | tags: map[string]string{ 99 | "usage": field.Tag.Get("usage"), 100 | "env_name": env, 101 | "env_full": sp.cfg.EnvPrefix + parentEnv + env, 102 | "flag_name": flag, 103 | "flag_full": sp.cfg.FlagPrefix + parentFlag + flag, 104 | }, 105 | isRequired: requiredTag == "true", 106 | } 107 | 108 | if !sp.cfg.SkipDefaults { 109 | // TODO: must be typed? 110 | pfield.defaultValue = field.Tag.Get("default") 111 | } 112 | 113 | if env == "-" { 114 | delete(pfield.tags, "env_full") 115 | } 116 | if flag == "-" { 117 | delete(pfield.tags, "flag_full") 118 | } 119 | 120 | if exactName, _, ok := strings.Cut(env, ",exact"); ok { 121 | pfield.tags["env_full"] = exactName 122 | } 123 | if exactName, _, ok := strings.Cut(flag, ",exact"); ok { 124 | pfield.tags["flag_full"] = exactName 125 | } 126 | 127 | if !sp.cfg.AllowDuplicates { 128 | name := pfield.tags["env_full"] 129 | if _, ok := sp.envNames[name]; ok && name != "" { 130 | return nil, fmt.Errorf("field %q is duplicated", name) 131 | } 132 | sp.envNames[name] = struct{}{} 133 | } 134 | 135 | if !sp.cfg.SkipFlags { 136 | flagName := pfield.tags["flag_full"] 137 | if flagName != "" { 138 | if _, ok := sp.flagNames[flagName]; ok && !sp.cfg.AllowDuplicates { 139 | return nil, fmt.Errorf("duplicate flag %q", flagName) 140 | } 141 | sp.flagNames[flagName] = struct{}{} 142 | // TODO: must be typed 143 | sp.flagSet.String(flagName, field.Tag.Get("default"), field.Tag.Get("usage")) 144 | } 145 | } 146 | 147 | if sp.cfg.DontGenerateTags { 148 | newName = name 149 | } 150 | for _, dec := range sp.cfg.FileDecoders { 151 | format := dec.Format() 152 | v := field.Tag.Get(format) 153 | if v == "" { 154 | v = newName 155 | } 156 | pfield.tags[format] = v 157 | } 158 | return pfield, nil 159 | } 160 | 161 | func (sp *structParser) parseStruct(x any) error { 162 | value := reflect.ValueOf(x) 163 | if value.Type().Kind() == reflect.Ptr { 164 | value = value.Elem() 165 | } 166 | 167 | fields, err := sp.parseStructHelper(nil, value, map[string]any{}) 168 | if err != nil { 169 | return err 170 | } 171 | sp.fields = fields 172 | 173 | // fmt.Printf("fields: %+v\n", fields) 174 | return nil 175 | } 176 | 177 | func (sp *structParser) parseStructHelper(parent *parsedField, structValue reflect.Value, res map[string]any) (map[string]any, error) { 178 | count := structValue.NumField() 179 | structType := structValue.Type() 180 | 181 | for i := 0; i < count; i++ { 182 | field := structType.Field(i) 183 | fieldValue := structValue.Field(i) 184 | fieldType := fieldValue.Type() 185 | if !fieldValue.CanSet() { 186 | continue 187 | } 188 | 189 | defaultTagValue := field.Tag.Get("default") 190 | pfield, err := sp.newParseField(parent, field) 191 | if err != nil { 192 | return nil, err 193 | } 194 | 195 | // do not set defaultValue for struct or pointer type without a default value 196 | // if fieldType.Kind() == reflect.Struct || 197 | // (fieldType.Kind() == reflect.Pointer && defaultTagValue == "") { 198 | // pfield.defaultValue = nil 199 | // } 200 | 201 | if fieldType.Kind() == reflect.Pointer { 202 | fieldValue = fieldValue.Elem() 203 | fieldValue = reflect.New(fieldType) 204 | fieldType = fieldValue.Type() 205 | } 206 | 207 | value := fieldValue.Interface() // to have 'value' of type field 208 | 209 | // if !sp.cfg.SkipDefaults { 210 | // pv := fieldValue.Addr().Interface() 211 | // if v, ok := pv.(encoding.TextUnmarshaler); ok { 212 | // value = defaultTagValue 213 | // err := v.UnmarshalText([]byte(fmt.Sprint(value))) 214 | // if err != nil { 215 | // return nil, err 216 | // } 217 | // } 218 | // pfield.value = 219 | // res[pfield.name] = pfield 220 | // continue 221 | // } 222 | 223 | switch fieldType.Kind() { 224 | // case reflect.Array: 225 | // TODO: same as slice + check len? 226 | 227 | case reflect.Interface: 228 | // TODO: just assign? 229 | 230 | case reflect.Struct: 231 | pfield.hasChilds = true 232 | 233 | param := map[string]any{} 234 | parent := pfield 235 | if field.Anonymous { 236 | pfield.hasChilds = false 237 | param = res 238 | parent = pfield.parent 239 | } 240 | 241 | values, err := sp.parseStructHelper(parent, fieldValue, param) 242 | if err != nil { 243 | return nil, err 244 | } 245 | // fmt.Printf("field: %+v got: %+v\n\n", pfield.name, values) 246 | 247 | value = values 248 | 249 | case reflect.Slice, reflect.Array: 250 | if isPrimitive(field.Type.Elem()) { 251 | // byte-slice case 252 | if field.Type.Elem().Kind() == reflect.Uint8 { 253 | value = []byte(defaultTagValue) 254 | } else { 255 | values := []any{} 256 | if defaultTagValue != "" && !strings.Contains(defaultTagValue, ",") { 257 | return nil, fmt.Errorf("incorrect default tag value for slice/array: %v", defaultTagValue) 258 | } 259 | for _, val := range strings.Split(defaultTagValue, ",") { 260 | values = append(values, val) 261 | } 262 | value = values 263 | } 264 | } else { 265 | pfield.hasChilds = true 266 | // TODO: if value is struct - parse 267 | // value = parseSlice(fieldValue, map[string]any{}) 268 | } 269 | 270 | // if !sp.cfg.SkipDefaults { 271 | // pfield.value = value 272 | // } 273 | 274 | case reflect.Map: 275 | // if isPrimitive(field.Type.Elem()) { 276 | values := map[string]any{} 277 | parts := strings.Split(defaultTagValue, ",") 278 | if defaultTagValue != "" && !strings.Contains(defaultTagValue, ",") { 279 | return nil, fmt.Errorf("incorrect default tag value for map: %v", defaultTagValue) 280 | } 281 | 282 | if len(parts) > 1 { 283 | for _, entry := range parts { 284 | // fmt.Printf("parts: %+v\n", parts) 285 | entries := strings.SplitN(entry, ":", 2) 286 | if len(entries) != 2 { 287 | return nil, fmt.Errorf("want 2 parts got %d (%s)", len(entries), entries) 288 | } 289 | // TODO: convert entry[1] to a primitive? 290 | values[entries[0]] = entries[1] 291 | } 292 | } 293 | value = values 294 | // } else { 295 | // pfield.hasChilds = true 296 | // } 297 | 298 | default: 299 | // TODO: do not set pointer 300 | if fieldType.Kind() == reflect.Pointer && defaultTagValue == "" { 301 | // skip 302 | value = nil 303 | } else { 304 | // TODO: when WeaklyTypedInput will be false use decodePrimitive(...) 305 | if !sp.cfg.SkipDefaults { 306 | value = defaultTagValue 307 | if fieldType == reflect.TypeOf(time.Second) { 308 | val, err := time.ParseDuration(defaultTagValue) 309 | if err != nil { 310 | return nil, err 311 | } 312 | value = val 313 | } 314 | } 315 | } 316 | } 317 | 318 | // we should not overwrite struct because there are childs 319 | if sp.cfg.SkipDefaults && fieldType.Kind() != reflect.Struct { 320 | pfield.value = fieldValue.Interface() 321 | } else { 322 | pfield.value = value 323 | } 324 | 325 | // fmt.Printf("def: %v %T '%+v'\n", fieldType.String(), value, value) 326 | res[pfield.name] = pfield 327 | } 328 | return res, nil 329 | } 330 | 331 | var fieldType = reflect.TypeOf(&parsedField{}) 332 | 333 | var hook = mapstructure.DecodeHookFuncType(func(from, to reflect.Type, data any) (any, error) { 334 | if from != fieldType { 335 | // fmt.Printf("hook: got %T (%+v) when %s\n", i, i, to.String()) 336 | return data, nil 337 | } 338 | field := data.(*parsedField) 339 | 340 | ifaceTo := reflect.New(to).Interface() 341 | if unmarshaller, ok := ifaceTo.(encoding.TextUnmarshaler); ok { 342 | // TODO: only string can be here? 343 | b := []byte(field.value.(string)) 344 | err := unmarshaller.UnmarshalText(b) 345 | return unmarshaller, err 346 | } 347 | // fmt.Printf("hook: when %s do '%+v' // %+v\n\n", to.String(), field.value, field) 348 | return field.value, nil 349 | }) 350 | 351 | func (sp *structParser) apply(x any) error { 352 | dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 353 | Result: x, 354 | DecodeHook: hook, 355 | WeaklyTypedInput: true, // TODO: temp fix? 356 | }) 357 | if err != nil { 358 | panic(fmt.Sprintf("aconfig: BUG with mapstructure.NewDecoder: %v", err)) 359 | } 360 | 361 | if err := dec.Decode(sp.fields); err != nil { 362 | return fmt.Errorf("decode: %w", err) 363 | } 364 | return nil 365 | } 366 | 367 | func (sp *structParser) applyLevel(tag string, values map[string]any) error { 368 | if err := sp.applyLevelHelper2(sp.fields, tag, values); err != nil { 369 | return err 370 | } 371 | 372 | if !sp.cfg.AllowUnknownFields { 373 | for env, value := range values { 374 | return fmt.Errorf("unknown field in file %q: %s=%v (see AllowUnknownFields config param)", "file", env, value) 375 | } 376 | } 377 | return nil 378 | } 379 | 380 | func (sp *structParser) applyLevelHelper2(fields map[string]any, tag string, values map[string]any) error { 381 | for _, field := range fields { 382 | pfield, ok := field.(*parsedField) 383 | if !ok { 384 | fmt.Printf("wat in level %T (%+v)\n", field, field) 385 | continue 386 | } 387 | tagValue, ok := pfield.tags[tag] 388 | if !ok { 389 | continue 390 | } 391 | value, ok := values[tagValue] 392 | if !ok { 393 | continue 394 | } 395 | 396 | switch value := value.(type) { 397 | case map[string]any: 398 | if pfield.hasChilds { 399 | pfieldValue, ok := pfield.value.(map[string]any) 400 | if !ok { 401 | fmt.Printf("ouch %T (%+v)\n", pfield.value, pfield.value) 402 | continue 403 | } 404 | err := sp.applyLevelHelper2(pfieldValue, tag, value) 405 | if err != nil { 406 | return err 407 | } 408 | } else { 409 | pfield.value = value 410 | } 411 | default: 412 | pfield.value = value 413 | } 414 | 415 | delete(values, tagValue) 416 | } 417 | return nil 418 | } 419 | 420 | func (sp *structParser) applyLevelHelper(fields map[string]any, tag string, values map[string]any) error { 421 | for _, v := range fields { 422 | field, ok := v.(*parsedField) 423 | if !ok { 424 | // fmt.Printf("got type %T (%v)\n", v, v) 425 | continue 426 | } 427 | 428 | want := field.tags[tag] 429 | value, ok := values[want] 430 | if !ok { 431 | continue 432 | } 433 | vval, ok := value.(map[string]any) 434 | 435 | // TODO: can be only for leaf nodes? 436 | if !ok { 437 | // fmt.Printf("got val %T (%v)\n", val, val) 438 | field.value = value 439 | continue 440 | } 441 | 442 | // fmt.Printf("got map: %+v %T\n", vval, vval) 443 | 444 | // no struct in childs - simple apply, mapstructure will take care 445 | if field.childs == nil { 446 | // TODO: reencode values? 447 | field.value = vval 448 | } else { 449 | if err := sp.applyLevelHelper(field.childs, tag, vval); err != nil { 450 | return err 451 | } 452 | } 453 | } 454 | return nil 455 | } 456 | 457 | func (sp *structParser) applyFlat(tag string, values map[string]any) error { 458 | allowUnknown := true 459 | prefix := "" 460 | 461 | switch tag { 462 | case "env": 463 | allowUnknown, prefix = sp.cfg.AllowUnknownEnvs, sp.cfg.EnvPrefix 464 | case "flag": 465 | allowUnknown, prefix = sp.cfg.AllowUnknownFlags, sp.cfg.FlagPrefix 466 | } 467 | 468 | dupls := map[string]struct{}{} 469 | 470 | if err := sp.applyFlatHelper(sp.fields, tag, values); err != nil { 471 | return err 472 | } 473 | 474 | if allowUnknown || prefix == "" { 475 | return nil 476 | } 477 | 478 | for name := range dupls { 479 | delete(values, name) 480 | } 481 | for key, value := range values { 482 | if strings.HasPrefix(key, prefix) { 483 | return fmt.Errorf("unknown %s %s=%v (see AllowUnknownXXX config param)", tag, key, value) 484 | } 485 | } 486 | return nil 487 | } 488 | 489 | func (sp *structParser) applyFlatHelper(fields map[string]any, tag string, values map[string]any) error { 490 | for _, field := range fields { 491 | pfield, ok := field.(*parsedField) 492 | if !ok { 493 | fmt.Printf("wat in flat %T (%+v)\n", field, field) 494 | continue 495 | } 496 | 497 | tagValue, ok := pfield.tags[tag+"_full"] 498 | if !ok { 499 | continue 500 | } 501 | value, ok := values[tagValue] 502 | if !ok { 503 | if !pfield.hasChilds { 504 | continue 505 | } 506 | if err := sp.applyFlatHelper(pfield.value.(map[string]any), tag, values); err != nil { 507 | return err 508 | } 509 | continue 510 | } 511 | 512 | pfield.value = value 513 | if !sp.cfg.AllowDuplicates { 514 | delete(values, tagValue) 515 | } 516 | } 517 | return nil 518 | } 519 | 520 | func isPrimitive(v reflect.Type) bool { 521 | return v.Kind() < reflect.Array || v.Kind() == reflect.String 522 | } 523 | -------------------------------------------------------------------------------- /reflection.go: -------------------------------------------------------------------------------- 1 | package aconfig 2 | 3 | import ( 4 | "encoding" 5 | "fmt" 6 | "reflect" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type fieldData struct { 13 | name string 14 | parent *fieldData 15 | field reflect.StructField 16 | value reflect.Value 17 | isSet bool 18 | isRequired bool 19 | tags map[string]string 20 | } 21 | 22 | func (f *fieldData) Name() string { 23 | return f.name 24 | } 25 | 26 | func (f *fieldData) Tag(tag string) string { 27 | if t, ok := f.tags[tag]; ok { 28 | return t 29 | } 30 | return f.field.Tag.Get(tag) 31 | } 32 | 33 | func (f *fieldData) Parent() (Field, bool) { 34 | return f.parent, f.parent != nil 35 | } 36 | 37 | func (l *Loader) newSimpleFieldData(value reflect.Value) *fieldData { 38 | return l.newFieldData(reflect.StructField{}, value, nil) 39 | } 40 | 41 | func (l *Loader) newFieldData(field reflect.StructField, value reflect.Value, parent *fieldData) *fieldData { 42 | requiredTag := field.Tag.Get("required") 43 | if requiredTag != "" && requiredTag != "true" { 44 | panic(fmt.Sprintf("aconfig: incorrect value for 'required' tag: %v", requiredTag)) 45 | } 46 | 47 | fd := &fieldData{ 48 | name: makeName(field.Name, parent), 49 | parent: parent, 50 | value: value, 51 | field: field, 52 | isSet: false, 53 | isRequired: requiredTag == "true", 54 | tags: l.tagsForField(field), 55 | } 56 | return fd 57 | } 58 | 59 | func (l *Loader) tagsForField(field reflect.StructField) map[string]string { 60 | words := splitNameByWords(field.Name) 61 | 62 | tags := map[string]string{ 63 | "default": field.Tag.Get("default"), 64 | "usage": field.Tag.Get("usage"), 65 | 66 | "env": l.makeTagValue(field, "env", words), 67 | "flag": l.makeTagValue(field, "flag", words), 68 | } 69 | 70 | for _, dec := range l.config.FileDecoders { 71 | tags[dec.Format()] = l.makeTagValue(field, dec.Format(), words) 72 | } 73 | return tags 74 | } 75 | 76 | func (l *Loader) fullTag(prefix string, f *fieldData, tag string) string { 77 | sep := "." 78 | if tag == "flag" { 79 | sep = l.config.FlagDelimiter 80 | } 81 | if tag == "env" { 82 | sep = l.config.envDelimiter 83 | } 84 | res := f.Tag(tag) 85 | if res == "-" { 86 | return "" 87 | } 88 | if before, _, ok := cut(res, ",exact"); ok { 89 | return before 90 | } 91 | if before, _, ok := cut(res, ",omitempty"); ok { 92 | return before 93 | } 94 | for p := f.parent; p != nil; p = p.parent { 95 | if p.Tag(tag) != "-" { 96 | res = p.Tag(tag) + sep + res 97 | } 98 | } 99 | return prefix + res 100 | } 101 | 102 | func (l *Loader) getFields(x interface{}) []*fieldData { 103 | value := reflect.ValueOf(x) 104 | for value.Type().Kind() == reflect.Ptr { 105 | value = value.Elem() 106 | } 107 | return l.getFieldsHelper(value, nil) 108 | } 109 | 110 | func (l *Loader) getFieldsHelper(valueObject reflect.Value, parent *fieldData) []*fieldData { 111 | typeObject := valueObject.Type() 112 | count := valueObject.NumField() 113 | 114 | fields := make([]*fieldData, 0, count) 115 | for i := 0; i < count; i++ { 116 | value := valueObject.Field(i) 117 | field := typeObject.Field(i) 118 | 119 | if !value.CanSet() { 120 | continue 121 | } 122 | 123 | fd := l.newFieldData(field, value, parent) 124 | 125 | // if it's a struct - expand and process it's fields 126 | kind := field.Type.Kind() 127 | if kind == reflect.Ptr { 128 | kind = field.Type.Elem().Kind() 129 | } 130 | if kind == reflect.Struct { 131 | var subFieldParent *fieldData 132 | if field.Anonymous { 133 | subFieldParent = parent 134 | } else { 135 | subFieldParent = fd 136 | } 137 | if field.Type.Kind() == reflect.Ptr { 138 | value.Set(reflect.New(field.Type.Elem())) 139 | value = value.Elem() 140 | } 141 | fields = append(fields, l.getFieldsHelper(value, subFieldParent)...) 142 | continue 143 | } 144 | fields = append(fields, fd) 145 | } 146 | return fields 147 | } 148 | 149 | func (l *Loader) setFieldData(field *fieldData, value interface{}) error { 150 | if value == nil { 151 | return nil 152 | } 153 | 154 | // unwrap pointers 155 | for field.value.Type().Kind() == reflect.Ptr { 156 | if field.value.IsNil() { 157 | field.value.Set(reflect.New(field.value.Type().Elem())) 158 | } 159 | field.value = field.value.Elem() 160 | } 161 | 162 | if value == "" { 163 | return nil 164 | } 165 | 166 | if field.value.CanAddr() { 167 | pv := field.value.Addr().Interface() 168 | if v, ok := pv.(encoding.TextUnmarshaler); ok { 169 | return v.UnmarshalText([]byte(fmt.Sprint(value))) 170 | } 171 | } 172 | 173 | switch kind := field.value.Type().Kind(); kind { 174 | case reflect.Bool: 175 | return l.setBool(field, fmt.Sprint(value)) 176 | 177 | case reflect.String: 178 | return l.setString(field, fmt.Sprint(value)) 179 | 180 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: 181 | return l.setInt(field, fmt.Sprint(value)) 182 | 183 | case reflect.Int64: 184 | return l.setInt64(field, fmt.Sprint(value)) 185 | 186 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: 187 | return l.setUint(field, fmt.Sprint(value)) 188 | 189 | case reflect.Float32, reflect.Float64: 190 | return l.setFloat(field, fmt.Sprint(value)) 191 | 192 | case reflect.Interface: 193 | return l.setInterface(field, value) 194 | 195 | case reflect.Struct: 196 | fd := l.newFieldData(reflect.StructField{}, field.value, nil) 197 | return l.m2s(mii(value), fd.value) 198 | 199 | case reflect.Slice: 200 | if isPrimitive(field.field.Type.Elem()) { 201 | return l.setSlice(field, sliceToString(value)) 202 | } 203 | 204 | in := reflect.ValueOf(value) 205 | if in.Kind() != reflect.Slice { 206 | panic(fmt.Errorf("%T %v", value, value)) 207 | } 208 | 209 | out := reflect.MakeSlice(field.field.Type, in.Len(), in.Len()) 210 | field.value.Set(out) 211 | 212 | for i := 0; i < in.Len(); i++ { 213 | fd := l.newFieldData(reflect.StructField{}, out.Index(i), nil) 214 | 215 | if err := l.setFieldData(fd, in.Index(i).Interface()); err != nil { 216 | return err 217 | } 218 | } 219 | 220 | return nil 221 | 222 | case reflect.Map: 223 | v, ok := value.(map[string]interface{}) 224 | if !ok { 225 | return l.setMap(field, fmt.Sprint(value)) 226 | } 227 | 228 | mapp := reflect.MakeMapWithSize(field.field.Type, len(v)) 229 | for key, val := range v { 230 | fdk := l.newSimpleFieldData(reflect.New(field.field.Type.Key()).Elem()) 231 | if err := l.setFieldData(fdk, key); err != nil { 232 | return fmt.Errorf("incorrect map key %q: %w", key, err) 233 | } 234 | 235 | fdv := l.newFieldData(reflect.StructField{}, reflect.New(field.value.Type().Elem()).Elem(), field) 236 | fdv.field.Type = field.value.Type().Elem() 237 | if err := l.setFieldData(fdv, val); err != nil { 238 | return fmt.Errorf("incorrect map value %q: %w", val, err) 239 | } 240 | 241 | mapp.SetMapIndex(fdk.value, fdv.value) 242 | } 243 | field.value.Set(mapp) 244 | return nil 245 | 246 | default: 247 | return fmt.Errorf("type kind %q isn't supported", kind) 248 | } 249 | } 250 | 251 | func (*Loader) setBool(field *fieldData, value string) error { 252 | val, err := strconv.ParseBool(value) 253 | if err != nil { 254 | return err 255 | } 256 | field.value.SetBool(val) 257 | return nil 258 | } 259 | 260 | func (*Loader) setInt(field *fieldData, value string) error { 261 | val, err := strconv.ParseInt(value, 0, field.value.Type().Bits()) 262 | if err != nil { 263 | return err 264 | } 265 | field.value.SetInt(val) 266 | return nil 267 | } 268 | 269 | func (l *Loader) setInt64(field *fieldData, value string) error { 270 | if field.field.Type == reflect.TypeOf(time.Second) { 271 | val, err := time.ParseDuration(value) 272 | if err != nil { 273 | return err 274 | } 275 | field.value.Set(reflect.ValueOf(val)) 276 | return nil 277 | } 278 | return l.setInt(field, value) 279 | } 280 | 281 | func (*Loader) setUint(field *fieldData, value string) error { 282 | val, err := strconv.ParseUint(value, 0, field.value.Type().Bits()) 283 | if err != nil { 284 | return err 285 | } 286 | field.value.SetUint(val) 287 | return nil 288 | } 289 | 290 | func (*Loader) setFloat(field *fieldData, value string) error { 291 | val, err := strconv.ParseFloat(value, field.value.Type().Bits()) 292 | if err != nil { 293 | return err 294 | } 295 | field.value.SetFloat(val) 296 | return nil 297 | } 298 | 299 | func (*Loader) setString(field *fieldData, value string) error { 300 | field.value.SetString(value) 301 | return nil 302 | } 303 | 304 | func (*Loader) setInterface(field *fieldData, value interface{}) error { 305 | field.value.Set(reflect.ValueOf(value)) 306 | return nil 307 | } 308 | 309 | func (l *Loader) setSlice(field *fieldData, value string) error { 310 | // Special case for []byte 311 | if field.field.Type.Elem().Kind() == reflect.Uint8 { 312 | value := reflect.ValueOf([]byte(value)) 313 | field.value.Set(value) 314 | return nil 315 | } 316 | 317 | vals := strings.Split(value, ",") 318 | slice := reflect.MakeSlice(field.field.Type, len(vals), len(vals)) 319 | for i, val := range vals { 320 | val = strings.TrimSpace(val) 321 | 322 | fd := l.newFieldData(reflect.StructField{}, slice.Index(i), nil) 323 | fd.field.Type = field.field.Type.Elem() 324 | if err := l.setFieldData(fd, val); err != nil { 325 | return fmt.Errorf("incorrect slice item %q: %w", val, err) 326 | } 327 | } 328 | field.value.Set(slice) 329 | return nil 330 | } 331 | 332 | func (l *Loader) setMap(field *fieldData, value string) error { 333 | vals := strings.Split(value, ",") 334 | mapField := reflect.MakeMapWithSize(field.field.Type, len(vals)) 335 | 336 | for _, val := range vals { 337 | entry := strings.SplitN(val, ":", 2) 338 | if len(entry) != 2 { 339 | return fmt.Errorf("incorrect map item: %s", val) 340 | } 341 | key := strings.TrimSpace(entry[0]) 342 | val := strings.TrimSpace(entry[1]) 343 | 344 | fdk := l.newSimpleFieldData(reflect.New(field.field.Type.Key()).Elem()) 345 | if err := l.setFieldData(fdk, key); err != nil { 346 | return fmt.Errorf("incorrect map key %q: %w", key, err) 347 | } 348 | 349 | fdv := l.newFieldData(reflect.StructField{}, reflect.New(field.value.Type().Elem()).Elem(), field) 350 | fdv.field.Type = field.value.Type().Elem() 351 | if err := l.setFieldData(fdv, val); err != nil { 352 | return fmt.Errorf("incorrect map value %q: %w", val, err) 353 | } 354 | mapField.SetMapIndex(fdk.value, fdv.value) 355 | } 356 | field.value.Set(mapField) 357 | return nil 358 | } 359 | 360 | func (l *Loader) m2s(m map[string]interface{}, structValue reflect.Value) error { 361 | for name, value := range m { 362 | name = strings.Title(name) 363 | structFieldValue := structValue.FieldByName(name) 364 | if !structFieldValue.IsValid() { 365 | return fmt.Errorf("no such field %q in struct", name) 366 | } 367 | 368 | if !structFieldValue.CanSet() { 369 | return fmt.Errorf("cannot set %q field value", name) 370 | } 371 | 372 | field, _ := structValue.Type().FieldByName(name) 373 | 374 | fd := l.newFieldData(field, structFieldValue, nil) 375 | if err := l.setFieldData(fd, value); err != nil { 376 | return err 377 | } 378 | } 379 | return nil 380 | } 381 | 382 | func mii(m interface{}) map[string]interface{} { 383 | switch m := m.(type) { 384 | case map[string]interface{}: 385 | return m 386 | case map[interface{}]interface{}: 387 | res := map[string]interface{}{} 388 | for k, v := range m { 389 | res[k.(string)] = v 390 | } 391 | return res 392 | default: 393 | panic(fmt.Sprintf("%T %v", m, m)) 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /testdata/bad_config.json: -------------------------------------------------------------------------------- 1 | { 2 | BOO 3 | } -------------------------------------------------------------------------------- /testdata/complex.json: -------------------------------------------------------------------------------- 1 | { 2 | "vcenter": { 3 | "user": "user-test", 4 | "password": "pass-test", 5 | "port": 8080, 6 | "datacenters": [ 7 | { 8 | "region": "region-test", 9 | "addresses": [ 10 | { 11 | "zone": "zone-test", 12 | "address": "address-test", 13 | "datacenter": "datacenter-test" 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /testdata/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "str": "str-json", 3 | "bytes": "Ynl0ZXMtanNvbg==", 4 | "int": 101, 5 | "http_port": 65000, 6 | "sub": { 7 | "float": 999.111 8 | }, 9 | "anon": { 10 | "is_anon": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /testdata/config1.json: -------------------------------------------------------------------------------- 1 | { 2 | "str": "111", 3 | "http_port": 111 4 | } 5 | -------------------------------------------------------------------------------- /testdata/config2.json: -------------------------------------------------------------------------------- 1 | { 2 | "http_port": 222 3 | } 4 | -------------------------------------------------------------------------------- /testdata/config3.json: -------------------------------------------------------------------------------- 1 | { 2 | "sub": { 3 | "float": 333.333 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /testdata/example_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "http_port": 2222, 3 | "auth": { 4 | "user": "json-user", 5 | "pass": "json-pass" 6 | } 7 | } -------------------------------------------------------------------------------- /testdata/slice-deep-structs.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": [ 3 | { 4 | "nested": { 5 | "name": "service1", 6 | "strings": ["string1", "string2"], 7 | "integers": [1, 2], 8 | "nullable": null, 9 | "booleans": [true, false], 10 | "structs": [{ "key": 1 }, { "key": 2 }, null, { "key": 3 }] 11 | } 12 | }, 13 | null 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /testdata/slice-struct-primitive-slice.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": [ 3 | { 4 | "name": "service1", 5 | "strings": ["string1", "string2"], 6 | "integers": [1, 2], 7 | "booleans": [true, false] 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /testdata/toy.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "foo": 0.4, 4 | "bar": 0.25 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /testdata/unknown_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "str": "defined", 3 | "unknown": 42 4 | } -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package aconfig 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "io/fs" 8 | "os" 9 | "reflect" 10 | "strings" 11 | "unicode" 12 | ) 13 | 14 | func assertStruct(x interface{}) { 15 | if x == nil { 16 | panic("aconfig: destination cannot be nil") 17 | } 18 | value := reflect.ValueOf(x) 19 | if value.Type().Kind() != reflect.Ptr { 20 | panic("aconfig: destination must be a pointer") 21 | } 22 | if value.Type().Kind() == reflect.Ptr { 23 | value = value.Elem() 24 | } 25 | if value.Kind() != reflect.Struct { 26 | panic("aconfig: destination must be struct") 27 | } 28 | } 29 | 30 | func getEnv(env []string) map[string]interface{} { 31 | res := make(map[string]interface{}, len(env)) 32 | 33 | for _, s := range env { 34 | for j := 0; j < len(s); j++ { 35 | if s[j] == '=' { 36 | key, value := s[:j], s[j+1:] 37 | res[key] = value 38 | break 39 | } 40 | } 41 | } 42 | return res 43 | } 44 | 45 | func getFlags(flagSet *flag.FlagSet) map[string]interface{} { 46 | res := map[string]interface{}{} 47 | flagSet.Visit(func(f *flag.Flag) { 48 | res[f.Name] = f.Value.String() 49 | }) 50 | return res 51 | } 52 | 53 | func getActualFlag(name string, flagSet *flag.FlagSet) *flag.Flag { 54 | var found *flag.Flag 55 | flagSet.Visit(func(f *flag.Flag) { 56 | if f.Name == name { 57 | found = f 58 | } 59 | }) 60 | return found 61 | } 62 | 63 | func makeName(name string, parent *fieldData) string { 64 | if parent == nil { 65 | return name 66 | } 67 | return parent.name + "." + name 68 | } 69 | 70 | func (l *Loader) makeTagValue(field reflect.StructField, tag string, words []string) string { 71 | if v := field.Tag.Get(tag); v != "" { 72 | return v 73 | } 74 | 75 | for _, dec := range l.config.FileDecoders { 76 | if tag == dec.Format() { 77 | if l.config.DontGenerateTags { 78 | return field.Name 79 | } 80 | } 81 | } 82 | 83 | name := strings.Join(words, "_") 84 | if tag == "env" { 85 | return strings.ToUpper(name) 86 | } 87 | return strings.ToLower(name) 88 | } 89 | 90 | // based on https://github.com/fatih/camelcase 91 | func splitNameByWords(src string) []string { 92 | var runes [][]rune 93 | var lastClass, class int 94 | 95 | // split into fields based on class of unicode character 96 | for _, r := range src { 97 | switch { 98 | case unicode.IsLower(r): 99 | class = 1 100 | case unicode.IsUpper(r): 101 | class = 2 102 | case unicode.IsDigit(r): 103 | class = 3 104 | default: 105 | class = 4 106 | } 107 | if class == lastClass { 108 | sz := len(runes) - 1 109 | runes[sz] = append(runes[sz], r) 110 | } else { 111 | runes = append(runes, []rune{r}) 112 | } 113 | lastClass = class 114 | } 115 | 116 | // handle upper case -> lower case sequences, e.g. 117 | // "PDFL", "oader" -> "PDF", "Loader" 118 | for i := 0; i < len(runes)-1; i++ { 119 | if unicode.IsUpper(runes[i][0]) && unicode.IsLower(runes[i+1][0]) { 120 | runes[i+1] = append([]rune{runes[i][len(runes[i])-1]}, runes[i+1]...) 121 | runes[i] = runes[i][:len(runes[i])-1] 122 | } 123 | } 124 | 125 | words := make([]string, 0, len(runes)) 126 | for _, s := range runes { 127 | if len(s) > 0 { 128 | words = append(words, string(s)) 129 | } 130 | } 131 | return words 132 | } 133 | 134 | // copy-paste until https://github.com/golang/go/issues/46336 is fixed 135 | // returns: before, after, isFound 136 | func cut(s, sep string) (_, _ string, _ bool) { 137 | if i := strings.Index(s, sep); i >= 0 { 138 | return s[:i], s[i+len(sep):], true 139 | } 140 | return s, "", false 141 | } 142 | 143 | var _ fs.FS = &fsOrOS{} 144 | 145 | type fsOrOS struct{ fs.FS } 146 | 147 | func (f *fsOrOS) Open(name string) (fs.File, error) { 148 | if f.FS == nil { 149 | return os.Open(name) 150 | } 151 | return f.FS.Open(name) 152 | } 153 | 154 | type jsonDecoder struct { 155 | fsys fs.FS 156 | } 157 | 158 | func (d *jsonDecoder) Init(fsys fs.FS) { 159 | d.fsys = fsys 160 | } 161 | 162 | // Format of the decoder. 163 | func (d *jsonDecoder) Format() string { 164 | return "json" 165 | } 166 | 167 | // DecodeFile implements FileDecoder. 168 | func (d *jsonDecoder) DecodeFile(filename string) (map[string]interface{}, error) { 169 | f, err := d.fsys.Open(filename) 170 | if err != nil { 171 | return nil, err 172 | } 173 | defer f.Close() 174 | 175 | var raw map[string]interface{} 176 | if err := json.NewDecoder(f).Decode(&raw); err != nil { 177 | return nil, err 178 | } 179 | return raw, nil 180 | } 181 | 182 | func sliceToString(curr interface{}) string { 183 | switch curr := curr.(type) { 184 | case []interface{}: 185 | b := &strings.Builder{} 186 | for i, v := range curr { 187 | if i > 0 { 188 | b.WriteByte(',') 189 | } 190 | fmt.Fprint(b, v) 191 | } 192 | return b.String() 193 | case string: 194 | return curr 195 | default: 196 | panic(fmt.Sprintf("can't normalize %T %v", curr, curr)) 197 | } 198 | } 199 | 200 | func find(actualFields map[string]interface{}, name string) map[string]interface{} { 201 | if strings.LastIndex(name, ".") == -1 { 202 | return actualFields 203 | } 204 | 205 | subName := name[:strings.LastIndex(name, ".")] 206 | value, ok := actualFields[subName] 207 | if !ok { 208 | actualFields = find(actualFields, subName) 209 | value, ok = actualFields[subName] 210 | if !ok { 211 | return actualFields 212 | } 213 | } 214 | 215 | switch val := value.(type) { 216 | case map[string]interface{}: 217 | for k, v := range val { 218 | actualFields[subName+"."+k] = v 219 | } 220 | delete(actualFields, subName) 221 | case map[interface{}]interface{}: 222 | for k, v := range val { 223 | actualFields[subName+"."+fmt.Sprint(k)] = v 224 | } 225 | delete(actualFields, subName) 226 | case []map[string]interface{}: 227 | for _, m := range val { 228 | for k, v := range m { 229 | actualFields[subName+"."+k] = v 230 | } 231 | } 232 | delete(actualFields, subName) 233 | case []map[interface{}]interface{}: 234 | for _, m := range val { 235 | for k, v := range m { 236 | actualFields[subName+"."+fmt.Sprint(k)] = v 237 | } 238 | } 239 | delete(actualFields, subName) 240 | } 241 | return actualFields 242 | } 243 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package aconfig 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func Test_splitNameByWords(t *testing.T) { 9 | type args struct { 10 | src string 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | want []string 16 | }{ 17 | {"", args{""}, []string{}}, 18 | {"", args{"str"}, []string{"str"}}, 19 | {"", args{"apikey"}, []string{"apikey"}}, 20 | {"", args{"apiKey"}, []string{"api", "Key"}}, 21 | {"", args{"ApiKey"}, []string{"Api", "Key"}}, 22 | {"", args{"APIKey"}, []string{"API", "Key"}}, 23 | {"", args{"Type2"}, []string{"Type", "2"}}, 24 | {"", args{"Type∆"}, []string{"Type", "∆"}}, 25 | {"", args{"MarshalJSONStruct"}, []string{"Marshal", "JSON", "Struct"}}, 26 | } 27 | for _, tt := range tests { 28 | tt := tt 29 | t.Run(tt.name, func(t *testing.T) { 30 | if got := splitNameByWords(tt.args.src); !reflect.DeepEqual(got, tt.want) { 31 | t.Errorf("splitNameByWords() = %v, want %v", got, tt.want) 32 | } 33 | }) 34 | } 35 | } 36 | --------------------------------------------------------------------------------