diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..07177f9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Run tests with coverage + +on: + push: + +jobs: + test: + runs-on: ubuntu-20.04 + + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '1.18' + + - name: Lint code + uses: golangci/golangci-lint-action@v3 + with: + version: v1.45.2 + args: --timeout 2m0s + + - name: Vet code + run: go vet ./... + + - name: Run test + run: ./test.sh + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + files: ./coverage.txt \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..80231fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022 Adrien Petel + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..76a730c --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +[](https://goreportcard.com/report/github.com/feliixx/boa) +[](https://codecov.io/gh/feliixx/boa) +[](https://pkg.go.dev/github.com/feliixx/boa) + +## boa + +A small configuration library for go application with a viper-like API, but with limited scope and no external dependency + +It supports: + + * reading a config in JSON format + * setting default + + + +## example + + +```go +config := ` +{ + "http_server": { + "enabled": true, + "host": "127.0.0.1" + } +}` + +boa.SetDefault("http_server.port", 80) + +err := boa.ParseConfig(strings.NewReader(config)) +if err != nil { + log.Fatal(err) +} + +srvHost := boa.GetString("http_server.host") +srvPort := boa.GetInt("http_server.port") + +fmt.Printf("%s:%d", srvHost, srvPort) +// Output: 127.0.0.1:80 +``` + diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..ab776ba --- /dev/null +++ b/example_test.go @@ -0,0 +1,33 @@ +package boa_test + +import ( + "fmt" + "log" + "strings" + + "github.com/feliixx/boa" +) + +func Example() { + + config := ` + { + "http_server": { + "enabled": true, + "host": "127.0.0.1" + } + }` + + boa.SetDefault("http_server.port", 80) + + err := boa.ParseConfig(strings.NewReader(config)) + if err != nil { + log.Fatal(err) + } + + srvHost := boa.GetString("http_server.host") + srvPort := boa.GetInt("http_server.port") + + fmt.Printf("%s:%d", srvHost, srvPort) + // Output: 127.0.0.1:80 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9569a2e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/feliixx/boa + +go 1.18 diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..f6e6290 --- /dev/null +++ b/parser.go @@ -0,0 +1,184 @@ +package boa + +import ( + "encoding/json" + "fmt" + "io" + "reflect" + "strconv" + "strings" +) + +var ( + config map[string]any + defaults = map[string]any{} +) + +// ParseConfig reads the config from an io.Reader. +func ParseConfig(jsonConfig io.Reader) error { + + d := json.NewDecoder(jsonConfig) + d.UseNumber() + + return d.Decode(&config) +} + +// SetDefault set the default value for this key. +func SetDefault(key string, value any) { + + switch reflect.TypeOf(value).Kind() { + + case reflect.Int, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint32, + reflect.Uint64: + defaults[key] = json.Number(fmt.Sprintf("%d", value)) + + case reflect.Float64: + defaults[key] = json.Number(strconv.FormatFloat(value.(float64), 'g', -1, 64)) + default: + defaults[key] = value + } + +} + +// GetString returns the value associated with the key as a string. +func GetString(key string) string { + return cast[string](getValue(key)) +} + +// GetBool returns the value associated with the key as a bool. +func GetBool(key string) bool { + return cast[bool](getValue(key)) +} + +// GetInt returns the value associated with the key as an int. +func GetInt(key string) int { + + n := cast[json.Number](getValue(key)) + v, err := strconv.ParseInt(string(n), 10, 64) + if err != nil { + panic("Can't parse number '%s' as an int") + } + return int(v) +} + +// GetInt32 returns the value associated with the key as an int32 +func GetInt32(key string) int32 { + n := cast[json.Number](getValue(key)) + v, err := strconv.ParseInt(string(n), 10, 32) + if err != nil { + panic("Can't parse number '%s' as an int32") + } + return int32(v) +} + +// GetInt64 returns the value associated with the key as an int64. +func GetInt64(key string) int64 { + n := cast[json.Number](getValue(key)) + v, err := strconv.ParseInt(string(n), 10, 64) + if err != nil { + panic("Can't parse number '%s' as an int64") + } + return v +} + +// GetUint returns the value associated with the key as an uint. +func GetUint(key string) uint { + n := cast[json.Number](getValue(key)) + v, err := strconv.ParseUint(string(n), 10, 64) + if err != nil { + panic("Can't parse number '%s' as an uint") + } + return uint(v) +} + +// GetUint32 returns the value associated with the key as an uint32. +func GetUint32(key string) uint32 { + n := cast[json.Number](getValue(key)) + v, err := strconv.ParseUint(string(n), 10, 32) + if err != nil { + panic("Can't parse number '%s' as an uint32") + } + return uint32(v) +} + +// GetUint64 returns the value associated with the key as an uint64. +func GetUint64(key string) uint64 { + n := cast[json.Number](getValue(key)) + v, err := strconv.ParseUint(string(n), 10, 64) + if err != nil { + panic("Can't parse number '%s' as an uint64") + } + return uint64(v) +} + +// GetFloat64 returns the value associated with the key as a float64. +func GetFloat64(key string) float64 { + n := cast[json.Number](getValue(key)) + v, err := strconv.ParseFloat(string(n), 64) + if err != nil { + panic("Can't parse number '%s' as a float64") + } + return v +} + +// GetAny returns any value associated with the key. +func GetAny(key string) any { + return getValue(key) +} + +func getValue(key string) any { + + nProp := strings.Split(key, ".") + nested := config + + for i, prop := range nProp { + + if i == len(nProp)-1 { + + if v, ok := nested[prop]; ok { + return v + } + + path := "'" + strings.Join(nProp[:i], ".") + "'" + if path == "''" { + path = "root object" + } + + return getDefaultValueOrPanic( + key, + fmt.Sprintf("%s has no key '%s'", path, prop), + ) + } + + if _, ok := nested[prop].(map[string]any); !ok { + return getDefaultValueOrPanic( + key, + fmt.Sprintf("%s is not an object", strings.Join(nProp[:i+1], ".")), + ) + } + + nested = nested[prop].(map[string]any) + } + + return nil +} + +func getDefaultValueOrPanic(key, panicMsg string) any { + v, ok := defaults[key] + if !ok { + panic(panicMsg) + } + return v +} + +func cast[T any](v any) T { + s, ok := v.(T) + if !ok { + panic(fmt.Sprintf("'%v' is not a %s", v, reflect.TypeOf(s))) + } + return s +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..621c380 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,278 @@ +package boa_test + +import ( + "strings" + "testing" + + "github.com/feliixx/boa" +) + +func TestGetString(t *testing.T) { + + getStringTests := []struct { + name string + config string + property string + expected string + panic bool + panicMsg string + }{ + { + name: "single key", + config: `{ "key": "value" }`, + property: "key", + expected: "value", + }, + { + name: "non existing key", + config: `{ "key": "value" }`, + property: "yellow", + panic: true, + panicMsg: `root object has no key 'yellow'`, + }, + { + name: "one nested level", + config: `{ "root": { "key": "value"}}`, + property: "root.key", + expected: "value", + }, + { + name: "non existing nested", + config: `{"root": {"first": {"key":"value"}}}`, + property: "root.wrong.key", + panic: true, + panicMsg: `root.wrong is not an object`, + }, + { + name: "non existing nested key", + config: `{"root": {"first": {"key":"value"}}}`, + property: "root.first.yellow", + panic: true, + panicMsg: `'root.first' has no key 'yellow'`, + }, + { + name: "wrong type", + config: `{"root": {"first": {"key":true}}}`, + property: "root.first.key", + panic: true, + panicMsg: `'true' is not a string`, + }, + } + + for _, test := range getStringTests { + + tt := test + t.Run(tt.name, func(t *testing.T) { + + if tt.panic { + defer func() { + panic := recover() + if panic == nil { + t.Error("test should have triggered a panic") + } + + if panic != tt.panicMsg { + t.Errorf("expected '%s' but got '%s'", tt.panicMsg, panic) + } + }() + } + + loadConfig(t, tt.config) + + got := boa.GetString(tt.property) + if got != tt.expected { + t.Errorf("expected '%s' but got '%s'", tt.expected, got) + } + }) + } +} + +func TestAll(t *testing.T) { + + config := ` + { + "root": { + "string": "s", + "boolTrue": true, + "boolFalse": false, + "int": -1, + "int32": -32, + "int64": -64, + "uint": 1, + "uint32": 32, + "uint64": 64, + "float64": 1.23842323 + } + }` + + loadConfig(t, config) + + tests := []struct { + name string + want any + got any + }{ + { + name: "string", + want: "s", + got: boa.GetString("root.string"), + }, + { + name: "bool true", + want: true, + got: boa.GetBool("root.boolTrue"), + }, { + name: "bool false", + want: false, + got: boa.GetBool("root.boolFalse"), + }, + { + name: "int", + want: int(-1), + got: boa.GetInt("root.int"), + }, + { + name: "int32", + want: int32(-32), + got: boa.GetInt32("root.int32"), + }, + { + name: "int64", + want: int64(-64), + got: boa.GetInt64("root.int64"), + }, + { + name: "uint", + want: uint(1), + got: boa.GetUint("root.uint"), + }, + { + name: "uint32", + want: uint32(32), + got: boa.GetUint32("root.uint32"), + }, + { + name: "uint64", + want: uint64(64), + got: boa.GetUint64("root.uint64"), + }, + { + name: "float64", + want: 1.23842323, + got: boa.GetFloat64("root.float64"), + }, + } + + for _, test := range tests { + + tt := test + t.Run(tt.name, func(t *testing.T) { + if tt.want != tt.got { + t.Errorf("expected '%v' but got '%v'", tt.want, tt.got) + } + }) + } +} + +func TestSetDefaults(t *testing.T) { + + config := ` + { + "root": { + "string": "s" + } + }` + + loadConfig(t, config) + + boa.SetDefault("root.string", "unused") + boa.SetDefault("root.first", "first") + boa.SetDefault("root.first.second", "second") + boa.SetDefault("int", -12) + boa.SetDefault("int32", -2334) + boa.SetDefault("int64", -17286145274665) + boa.SetDefault("uint", 12) + boa.SetDefault("uint32", 2334) + boa.SetDefault("uint64", 17286145274665) + boa.SetDefault("float64", 0.72631524721) + boa.SetDefault("precision.float", 1563246315263.35152323132) + + tests := []struct { + name string + want any + got any + }{ + { + name: "default not used if key exist", + want: "s", + got: boa.GetString("root.string"), + }, + { + name: "default used if key does not exist", + want: "first", + got: boa.GetString("root.first"), + }, + { + name: "default used if nested object doesn't exist", + want: "second", + got: boa.GetString("root.first.second"), + }, + { + name: "default int", + want: -12, + got: boa.GetInt("int"), + }, + { + name: "default int32", + want: int32(-2334), + got: boa.GetInt32("int32"), + }, + { + name: "default int64", + want: int64(-17286145274665), + got: boa.GetInt64("int64"), + }, + { + name: "default uint", + want: uint(12), + got: boa.GetUint("uint"), + }, + { + name: "default uint32", + want: uint32(2334), + got: boa.GetUint32("uint32"), + }, + { + name: "default uint64", + want: uint64(17286145274665), + got: boa.GetUint64("uint64"), + }, + { + name: "default float", + want: 0.72631524721, + got: boa.GetFloat64("float64"), + }, + { + name: "precision float", + want: 1563246315263.35152323132, + got: boa.GetFloat64("precision.float"), + }, + } + + for _, test := range tests { + + tt := test + t.Run(tt.name, func(t *testing.T) { + if tt.want != tt.got { + t.Errorf("expected '%v' but got '%v'", tt.want, tt.got) + } + }) + } +} + +func loadConfig(t *testing.T, config string) { + err := boa.ParseConfig(strings.NewReader(config)) + if err != nil { + t.Error(err) + } +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..80d33f9 --- /dev/null +++ b/test.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -e +echo "" > coverage.txt + +for d in $(go list ./... | grep -v vendor); do + go test -race -coverprofile=profile.out -covermode=atomic $d + if [ -f profile.out ]; then + cat profile.out >> coverage.txt + rm profile.out + fi +done \ No newline at end of file