Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
aranw committed May 14, 2024
0 parents commit 0057c95
Show file tree
Hide file tree
Showing 9 changed files with 275 additions and 0 deletions.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 Aran Wilkinson.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# yamlcfg

yamlcfg is a wrapper around the [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) library and provides a convient way to configure Golang applications with YAML and environment variables.

The library can also automatically call `Validate` functions if present on the given config struct.

## Installation

To install, run:

```
go get github.com/aranw/yamlcfg
```

## License

The yamlcfg package is licensed under the MIT. Please see the LICENSE file for details.

## Example

```go
package main

import (
"log/slog"

"github.com/aranw/yamlcfg"
)

type Config struct {
LogLevel string `yaml:"log_level"`
}

func (c *Config) Validate() error {
validLevels := [...]string{"debug", "info", "error"}

validLevel := slices.ContainsFunc(validLevels[:], func(s string) bool {
return strings.EqualFold(s, c.Log.Level)
})

if c.Log.Level == "" {
c.Log.Level = "info"
} else if !validLevel {
return errors.New("invalid log level provided")
}

return nil
}

func main() {
cfg, err := yamlcfg.Load[Config]("config.yaml")
if err != nil {
slog.Error("loading yaml config", "err", err)
return
}

_ = cfg.LogLevel
}

```
44 changes: 44 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package yamlcfg

import (
"bytes"
"fmt"
"os"

"gopkg.in/yaml.v3"
)

// Load takes the given path and attempts to read and unmarshal the config
// The given config will also be validated if it has a Validate function on it
func Load[T any](path string) (*T, error) {
var cfg *T

b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
} else if err := UnmarshalConfig(&cfg, b); err != nil {
return nil, fmt.Errorf("unmarshalling config: %w", err)
}
if cfg, ok := interface{}(cfg).(interface {
Validate() error
}); ok {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validating config: %w", err)
}
}

return cfg, nil
}

// UnmarshalConfig takes the provided yaml data and unmarshals it
// into the provided config struct. It will return an error if the
// decoding fails or if the yaml data is not in the expected format.
// It will also expand any environment variables in the yaml data
func UnmarshalConfig[T any](cfg *T, data []byte) error {
// expand any $VAR values in the config from environment variables
data = []byte(os.ExpandEnv(string(data)))

dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
return dec.Decode(&cfg)
}
96 changes: 96 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package yamlcfg

import (
"fmt"
"testing"

"github.com/go-quicktest/qt"
)

type TestStruct struct {
SomeValue string `yaml:"some_value"`
}

func (t *TestStruct) Validate() error {
return nil
}

type TestStructWithFailingValidation struct {
SomeValue string `yaml:"some_value"`
}

func (t *TestStructWithFailingValidation) Validate() error {
return fmt.Errorf("this is going to fail")
}

func TestLoad(t *testing.T) {

t.Run("successfully load and unmarshals config", func(t *testing.T) {
cfg, err := Load[TestStruct]("testdata/test1.yaml")
if err != nil {
t.Fatal(err)
}

qt.Assert(t, qt.Equals(cfg.SomeValue, "this is for testing purposes"))
})

t.Run("fails to read unknown file", func(t *testing.T) {
cfg, err := Load[TestStruct]("testdata/this_file_does_not_exist.yaml")
qt.Assert(t, qt.IsNotNil(err))
qt.Assert(t, qt.ErrorMatches(err, "reading config file: .*"))
qt.Assert(t, qt.IsNil(cfg))
})

t.Run("fails to read wrong file type", func(t *testing.T) {
cfg, err := Load[TestStruct]("testdata/gopher.png")
qt.Assert(t, qt.IsNotNil(err))
qt.Assert(t, qt.ErrorMatches(err, "unmarshalling config: yaml: .*"))
qt.Assert(t, qt.IsNil(cfg))
})

t.Run("fails to validate config struct", func(t *testing.T) {
cfg, err := Load[TestStructWithFailingValidation]("testdata/test1.yaml")
qt.Assert(t, qt.IsNotNil(err))
qt.Assert(t, qt.ErrorMatches(err, "validating config: this is going to fail"))
qt.Assert(t, qt.IsNil(cfg))
})
}

func TestUnmarshalConfig(t *testing.T) {
t.Run("valid config", func(t *testing.T) {
b := []byte(`name: test`)
cfg := struct {
Name string `yaml:"name"`
}{}
if err := UnmarshalConfig(&cfg, b); err != nil {
t.Fatal(err)
}

qt.Assert(t, qt.Equals(cfg.Name, "test"))
})

t.Run("valid config with env vars", func(t *testing.T) {
t.Setenv("NAME", "testing")

b := []byte(`name: $NAME`)
cfg := struct {
Name string `yaml:"name"`
}{}
if err := UnmarshalConfig(&cfg, b); err != nil {
t.Fatal(err)
}

qt.Assert(t, qt.Equals(cfg.Name, "testing"))
})

t.Run("invalid config", func(t *testing.T) {
b := []byte(`asdasdasdad******`)

cfg := struct {
Name string `yaml:"name"`
}{}
if err := UnmarshalConfig(&cfg, b); err == nil {
t.Fatal("expected error")
}
})
}
21 changes: 21 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package yamlcfg_test

import (
"log/slog"

"github.com/aranw/yamlcfg"
)

type Config struct {
LogLevel string `yaml:"log_level"`
}

func ExampleLoad() {
cfg, err := yamlcfg.Load[Config]("config.yaml")
if err != nil {
slog.Error("loading yaml config", "err", err)
return
}

_ = cfg.LogLevel
}
15 changes: 15 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module github.com/aranw/yamlcfg

go 1.22.3

require (
github.com/go-quicktest/qt v1.101.0
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/google/go-cmp v0.5.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
)
17 changes: 17 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Binary file added testdata/gopher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions testdata/test1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
some_value: "this is for testing purposes"

0 comments on commit 0057c95

Please sign in to comment.