Skip to content

Commit

Permalink
Add konf.WithCaseSensitive to support case-sensitive path match (nil-…
Browse files Browse the repository at this point in the history
  • Loading branch information
ktong authored Mar 7, 2024
1 parent 752dbc8 commit fc4d0d1
Show file tree
Hide file tree
Showing 17 changed files with 270 additions and 118 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Add konf.WithCaseSensitive to support case-sensitive path match (#205).

## [0.8.1] - 2024-03-06

### Fixed
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,18 @@ configuration source(s). They read configuration in terms of functions in packag

```
func (app *appObject) Run() {
// Read the server configuration.
type serverConfig struct {
// Server configuration with default values.
serverConfig := struct {
Host string
Port int
}{
Host: "localhost",
Port: "8080",
}
// Read the server configuration.
if err := konf.Unmarshal("server", &serverConfig); err != nil {
// Handle error here.
}
cfg := konf.Get[serverConfig]("server")
// Register callbacks while server configuration changes.
konf.OnChange(func() {
Expand Down
57 changes: 38 additions & 19 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode"

"github.com/nil-go/konf/internal"
"github.com/nil-go/konf/internal/convert"
Expand All @@ -27,10 +28,11 @@ type Config struct {
nocopy internal.NoCopy[Config]

// Options.
delimiter string
converter convert.Converter
logger *slog.Logger
onStatus func(loader Loader, changed bool, err error)
caseSensitive bool
delimiter string
logger *slog.Logger
onStatus func(loader Loader, changed bool, err error)
converter convert.Converter

// Loaded configuration.
values map[string]any
Expand All @@ -57,6 +59,9 @@ func New(opts ...Option) *Config {
} else {
option.hooks = append(option.hooks, convert.WithTagName(option.tagName))
}
if !option.caseSensitive {
option.hooks = append(option.hooks, defaultKeyMap)
}
option.converter = convert.New(option.hooks...)

return &(option.Config)
Expand Down Expand Up @@ -96,22 +101,20 @@ func (c *Config) Load(loader Loader) error {
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
maps.Merge(c.values, values)

// Merged to empty map to convert to lower case.
providerValues := make(map[string]any)
maps.Merge(providerValues, values)
c.providers = append(c.providers, provider{
prd := provider{
loader: loader,
values: providerValues,
})
values: c.transformKeys(values),
}
c.providers = append(c.providers, prd)
maps.Merge(c.values, prd.values)

return nil
}

// Unmarshal reads configuration under the given path from the Config
// and decodes it into the given object pointed to by target.
// The path is case-insensitive.
// The path is case-insensitive unless konf.WithCaseSensitive is set.
func (c *Config) Unmarshal(path string, target any) error {
if c == nil {
return nil
Expand All @@ -124,25 +127,40 @@ func (c *Config) Unmarshal(path string, target any) error {
converter = defaultConverter
}

if err := converter.Convert(maps.Sub(c.values, c.split(path)), target); err != nil {
if err := converter.Convert(c.sub(c.values, path), target); err != nil {
return fmt.Errorf("decode: %w", err)
}

return nil
}

func (c *Config) split(key string) []string {
func (c *Config) sub(values map[string]any, path string) any {
delimiter := c.delimiter
if delimiter == "" {
delimiter = "."
}
if !c.caseSensitive {
path = toLower(path)
}

return maps.Sub(values, strings.Split(path, delimiter))
}

func (c *Config) transformKeys(m map[string]any) map[string]any {
if c.caseSensitive {
return m
}

return maps.TransformKeys(m, toLower)
}

return strings.Split(key, delimiter)
func toLower(s string) string {
return strings.Map(unicode.ToLower, s)
}

// Explain provides information about how Config resolve each value
// from loaders for the given path. It blur sensitive information.
// The path is case-insensitive.
// The path is case-insensitive unless konf.WithCaseSensitive is set.
func (c *Config) Explain(path string) string {
if c == nil {
return path + " has no configuration.\n\n"
Expand All @@ -151,7 +169,7 @@ func (c *Config) Explain(path string) string {
c.nocopy.Check()

explanation := &strings.Builder{}
c.explain(explanation, path, maps.Sub(c.values, c.split(path)))
c.explain(explanation, path, c.sub(c.values, path))

return explanation.String()
}
Expand All @@ -176,7 +194,7 @@ func (c *Config) explain(explanation *strings.Builder, path string, value any) {
}
var loaders []loaderValue
for _, provider := range c.providers {
if v := maps.Sub(provider.values, c.split(path)); v != nil {
if v := c.sub(provider.values, path); v != nil {
loaders = append(loaders, loaderValue{provider.loader, v})
}
}
Expand Down Expand Up @@ -215,6 +233,7 @@ type provider struct {
//nolint:gochecknoglobals
var (
defaultTagName = convert.WithTagName("konf")
defaultKeyMap = convert.WithKeyMapper(toLower)
defaultHooks = []convert.Option{
convert.WithHook[string, time.Duration](time.ParseDuration),
convert.WithHook[string, []string](func(f string) ([]string, error) {
Expand All @@ -225,6 +244,6 @@ var (
}),
}
defaultConverter = convert.New(
append(defaultHooks, defaultTagName)...,
append(defaultHooks, defaultTagName, defaultKeyMap)...,
)
)
14 changes: 14 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ func TestConfig_Unmarshal(t *testing.T) {
assert.Equal(t, "struct", value.Config)
},
},
{
description: "config for struct (case sensitive)",
opts: []konf.Option{konf.WithCaseSensitive()},
loaders: []konf.Loader{mapLoader{"ConfigValue": "struct"}},
assert: func(config *konf.Config) {
var value struct {
ConfigValue string
Configvalue string
}
assert.NoError(t, config.Unmarshal("", &value))
assert.Equal(t, "struct", value.ConfigValue)
assert.Equal(t, "", value.Configvalue)
},
},
{
description: "default delimiter",
loaders: []konf.Loader{
Expand Down
8 changes: 4 additions & 4 deletions default.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

// Get retrieves the value under the given path from the default Config.
// It returns the zero value of the expected type if there is an error.
// The path is case-insensitive.
// The path is case-insensitive unless konf.WithCaseSensitive is set.
func Get[T any](path string) T { //nolint:ireturn
var value T
if err := Unmarshal(path, &value); err != nil {
Expand All @@ -37,14 +37,14 @@ func Get[T any](path string) T { //nolint:ireturn

// Unmarshal reads configuration under the given path from the default Config
// and decodes it into the given object pointed to by target.
// The path is case-insensitive.
// The path is case-insensitive unless konf.WithCaseSensitive is set.
func Unmarshal(path string, target any) error {
return defaultConfig.Load().Unmarshal(path, target)
}

// OnChange registers a callback function that is executed
// when the value of any given path in the default Config changes.
// The paths are case-insensitive.
// The paths are case-insensitive unless konf.WithCaseSensitive is set.
//
// The register function must be non-blocking and usually completes instantly.
// If it requires a long time to complete, it should be executed in a separate goroutine.
Expand All @@ -56,7 +56,7 @@ func OnChange(onChange func(), paths ...string) {

// Explain provides information about how default Config resolve each value
// from loaders for the given path. It blur sensitive information.
// The path is case-insensitive.
// The path is case-insensitive unless konf.WithCaseSensitive is set.
func Explain(path string) string {
return defaultConfig.Load().Explain(path)
}
Expand Down
27 changes: 10 additions & 17 deletions internal/convert/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
type Converter struct {
hooks []hook
tagName string
keyMap func(string) string
}

func New(opts ...Option) Converter {
Expand Down Expand Up @@ -482,7 +483,6 @@ func (c Converter) convertStruct(name string, fromVal, toVal reflect.Value) erro
structs := make([]reflect.Value, 0, 5) //nolint:gomnd
structs = append(structs, toVal)

fromKeys := fromVal.MapKeys()
var errs []error
for len(structs) > 0 {
structVal := structs[0]
Expand All @@ -499,9 +499,9 @@ func (c Converter) convertStruct(name string, fromVal, toVal reflect.Value) erro
}

// It always parse the tags cause it's looking for other tags too
fileName, tag, _ := strings.Cut(fieldType.Tag.Get(c.tagName), ",")
if fileName == "" {
fileName = fieldType.Name
fieldName, tag, _ := strings.Cut(fieldType.Tag.Get(c.tagName), ",")
if fieldName == "" {
fieldName = fieldType.Name
}
if tag == "squash" {
if fieldVal.Kind() != reflect.Struct {
Expand All @@ -516,27 +516,20 @@ func (c Converter) convertStruct(name string, fromVal, toVal reflect.Value) erro
continue
}

elemVal := fromVal.MapIndex(reflect.ValueOf(fileName))
if !elemVal.IsValid() {
// Do a slower search by iterating over each key and
// doing case-insensitive search.
for _, fromKey := range fromKeys {
if strings.EqualFold(fromKey.String(), fileName) {
elemVal = fromVal.MapIndex(fromKey)

break
}
}
keyName := fieldName
if c.keyMap != nil {
keyName = c.keyMap(keyName)
}
elemVal := fromVal.MapIndex(reflect.ValueOf(keyName))
if !elemVal.IsValid() {
// There was no matching key in the map for the value in the struct.
continue
}

if name != "" {
fileName = name + "." + fileName
fieldName = name + "." + fieldName
}
if err := c.convert(fileName, elemVal.Interface(), pointer(fieldVal)); err != nil {
if err := c.convert(fieldName, elemVal.Interface(), pointer(fieldVal)); err != nil {
errs = append(errs, err)
}
}
Expand Down
Loading

0 comments on commit fc4d0d1

Please sign in to comment.