Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cc03f86
refactor(config): replace viper with direct YAML+pflag loading
evg4b May 15, 2026
47aff69
feat(config): add ConfigWatcher to replace viper.WatchConfig
evg4b May 15, 2026
491df1a
test(config): update tests to use new LoadConfiguration API
evg4b May 15, 2026
dcd3839
test(config): add tests for ConfigWatcher
evg4b May 15, 2026
4801e9b
refactor(main,uncors_app): remove viper, use new config API and Confi…
evg4b May 15, 2026
8b16dbb
chore: remove viper dependency
evg4b May 15, 2026
8c5bfee
test(config): add coverage tests for flag overrides and mapping merge
evg4b May 15, 2026
df6f4b7
fix(config): resolve duplicate err variable after linter rewrite
evg4b May 15, 2026
8c237a3
fix(lint): resolve all golangci-lint violations
evg4b May 15, 2026
3d57510
refactor(config): remove mapstructure, use native yaml.v3 decoding
evg4b May 15, 2026
a39faad
test(config): improve coverage to 96%+ on new decode logic
evg4b May 16, 2026
3c3301d
test: improve new-code coverage to satisfy SonarCloud gate
evg4b May 16, 2026
4e54192
test(uncors_app): cover handleServerStarted onChange callback via fil…
evg4b May 16, 2026
c0697ab
fix(test): avoid data race in TestHandleServerStartedCallbackOnFileCh…
evg4b May 16, 2026
d6fe2c8
fix(main): switch generate-certs flag set to ContinueOnError
evg4b May 16, 2026
9562721
test(config): cover watcher.go run() error-log and Events-!ok paths
evg4b May 16, 2026
eac90b5
test(config): cover watcher.go Errors-!ok path
evg4b May 16, 2026
5b911cd
test(config): add UnmarshalYAML tests for Response and CacheConfig
evg4b May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ linters:
- ok
- fs
- ca
tagliatelle:
case:
rules:
yaml: kebab
exclusions:
generated: lax
presets:
Expand Down
9 changes: 0 additions & 9 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@ require (
github.com/gorilla/mux v1.8.1
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-version v1.9.0
github.com/mitchellh/mapstructure v1.5.0
github.com/samber/lo v1.53.0
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/xeipuuv/gojsonschema v1.2.0
github.com/yuin/gopher-lua v1.1.2
Expand All @@ -38,9 +36,7 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/gkampitakis/ciinfo v0.3.4 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
Expand All @@ -49,7 +45,6 @@ require (
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
Expand All @@ -58,18 +53,14 @@ require (
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.20.0 // indirect
)

require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.10.0
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
Expand Down
20 changes: 0 additions & 20 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa5
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M=
github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z0KY=
Expand All @@ -49,14 +47,10 @@ github.com/gkampitakis/go-snaps v0.5.21 h1:SvhSFeZviQXwlT+dnGyAIATVehkhqRVW6qfQZ
github.com/gkampitakis/go-snaps v0.5.21/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs=
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9yue4+QkG/HQ/W67wvtQmWJ4SDo9aK/GIno=
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gojuno/minimock/v3 v3.4.7 h1:vhE5zpniyPDRT0DXd5s3DbtZJVlcbmC5k80izYtj9lY=
github.com/gojuno/minimock/v3 v3.4.7/go.mod h1:QxJk4mdPrVyYUmEZGc2yD2NONpqM/j4dWhsy9twjFHg=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
Expand All @@ -79,12 +73,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
Expand All @@ -94,27 +84,19 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
Expand All @@ -137,8 +119,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
Expand Down
52 changes: 49 additions & 3 deletions internal/config/cache_config.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
package config

import (
"errors"
"fmt"
"strings"
"time"

"gopkg.in/yaml.v3"
)

// ErrInvalidCacheConfig is returned when the cache-config YAML value is not a mapping.
var ErrInvalidCacheConfig = errors.New("expected a mapping for cache-config")

type CacheGlobs []string

func (g CacheGlobs) Clone() CacheGlobs {
Expand All @@ -18,9 +26,9 @@ func (g CacheGlobs) Clone() CacheGlobs {
}

type CacheConfig struct {
ExpirationTime time.Duration `mapstructure:"expiration-time"`
MaxSize int64 `mapstructure:"max-size"`
Methods []string `mapstructure:"methods"`
ExpirationTime time.Duration `yaml:"-"`
MaxSize int64 `yaml:"max-size"`
Methods []string `yaml:"methods"`
}

func (c *CacheConfig) Clone() *CacheConfig {
Expand All @@ -35,3 +43,41 @@ func (c *CacheConfig) Clone() *CacheConfig {
Methods: methods,
}
}

// UnmarshalYAML implements custom decoding so that the "expiration-time" field
// can be expressed as a human-readable duration string (e.g. "30m", "1h").
// Other fields are decoded by the standard yaml.v3 machinery.
// Only fields present in the YAML node are updated; existing values (defaults)
// are preserved for absent keys.
func (c *CacheConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return ErrInvalidCacheConfig
}

for i := 0; i+1 < len(value.Content); i += 2 {
keyNode := value.Content[i]
valNode := value.Content[i+1]

switch keyNode.Value {
case "expiration-time":
dur, err := time.ParseDuration(strings.ReplaceAll(valNode.Value, " ", ""))
if err != nil {
return fmt.Errorf("invalid expiration-time %q: %w", valNode.Value, err)
}

c.ExpirationTime = dur
case "max-size":
err := valNode.Decode(&c.MaxSize)
if err != nil {
return err
}
case "methods":
err := valNode.Decode(&c.Methods)
if err != nil {
return err
}
}
}

return nil
}
61 changes: 61 additions & 0 deletions internal/config/cache_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,69 @@ import (

"github.com/evg4b/uncors/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

func TestCacheConfigUnmarshalYAML(t *testing.T) {
t.Run("decodes all fields", func(t *testing.T) {
const input = `
expiration-time: 30m
max-size: 52428800
methods:
- GET
- POST
`

var actual config.CacheConfig

require.NoError(t, yaml.Unmarshal([]byte(input), &actual))
assert.Equal(t, config.CacheConfig{
ExpirationTime: 30 * time.Minute,
MaxSize: 52428800,
Methods: []string{http.MethodGet, http.MethodPost},
}, actual)
})

t.Run("parses expiration-time with embedded spaces", func(t *testing.T) {
const input = `expiration-time: "1h 30m"`

var actual config.CacheConfig

require.NoError(t, yaml.Unmarshal([]byte(input), &actual))
assert.Equal(t, 90*time.Minute, actual.ExpirationTime)
})

t.Run("absent fields keep zero values", func(t *testing.T) {
const input = `max-size: 1024`

var actual config.CacheConfig

require.NoError(t, yaml.Unmarshal([]byte(input), &actual))
assert.Equal(t, int64(1024), actual.MaxSize)
assert.Zero(t, actual.ExpirationTime)
assert.Nil(t, actual.Methods)
})

t.Run("returns ErrInvalidCacheConfig for non-mapping node", func(t *testing.T) {
const input = `- item1`

var actual config.CacheConfig

err := yaml.Unmarshal([]byte(input), &actual)

assert.ErrorIs(t, err, config.ErrInvalidCacheConfig)
})

t.Run("returns error for invalid expiration-time", func(t *testing.T) {
const input = `expiration-time: not-a-duration`

var actual config.CacheConfig

assert.Error(t, yaml.Unmarshal([]byte(input), &actual))
})
}

func TestCacheGlobsClone(t *testing.T) {
globs := config.CacheGlobs{
"/api/**",
Expand Down
Loading
Loading