From 130903a835c40c9328c4abfef5444bf7667c5cc4 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 2 Jun 2025 17:45:52 -0700 Subject: [PATCH] initial commit --- README.md | 3 + config.go | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++ directory.go | 52 +++++++++++++++++ go.mod | 34 +++++++++++ go.sum | 56 ++++++++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 README.md create mode 100644 config.go create mode 100644 directory.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff4446a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# config + +config is a tiny library to streamline configuration loading and validation using [viper](https://github.com/spf13/viper) and [validator](https://github.com/go-playground/validator). diff --git a/config.go b/config.go new file mode 100644 index 0000000..75d45d4 --- /dev/null +++ b/config.go @@ -0,0 +1,159 @@ +package config + +import ( + "fmt" + "path/filepath" + "strings" + "sync" + + "github.com/go-playground/validator/v10" + "github.com/spf13/viper" +) + +// Manager is the top-level configuration schema and viper instance. +type Manager[T struct{}] struct { + // PreLoad is an optional function that is called before loading the + // configuration file. + PreLoad func(*Manager[T]) error + + // PostLoad is an optional function that is called after loading the + // configuration file. + PostLoad func(*Manager[T]) error + + // R is a raw access to the configuration schema. WARNING: This does not + // perform ANY validation or unmarshalling, so it should only be used if you + // have already manually called the Load method and are sure that no errors + // occured. + R *T + + // loaded indicates whether the configuration has been loaded. + loaded bool + // mu is a mutex to ensure thread-safe access to the configuration. + mu sync.RWMutex + + viper *viper.Viper +} + +// NewManager creates a new configuration manager for schema T. Fields in T are +// responsible for including any necessary `mapstructure` and `validate` tags +// to ensure proper unmarshalling and validation of the configuration schema. +func NewManager[T struct{}](configName, configType, configPath string) *Manager[T] { + m := &Manager[T]{ + viper: viper.New(), + } + + m.viper.SetConfigName(configName) + m.viper.SetConfigType(configType) + m.viper.AddConfigPath(configPath) + + return m +} + +// WithPrefix sets the environment variable prefix for the viper instance. Must +// be set in order to use environment variables for configuration. +func (m *Manager[T]) WithPrefix(prefix string) *Manager[T] { + m.viper.SetEnvPrefix(prefix) + m.viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + m.viper.AutomaticEnv() + return m +} + +// C returns the configuration schema. If the configuration has not been +// loaded, C will load the configuration file before returning the schema. +// Any errors encountered during loading will result in a panic. +func (m *Manager[T]) C() *T { + // First, try to acquire a read lock + m.mu.RLock() + if m.loaded { + m.mu.RUnlock() + return m.R // Already loaded, return the cached instance + } + m.mu.RUnlock() + + // Need to load the configuration, so acquire a write lock + m.mu.Lock() + defer m.mu.Unlock() + + // Double-check if loaded after acquiring the write lock + if m.loaded { + return m.R // Already loaded, return the cached instance + } + + // Proceed to load the configuration + if err := m.loadUnsafe(); err != nil { + panic(fmt.Errorf("failed to load configuration: %w", err)) + } + + return m.R // Return the loaded configuration +} + +// ConfigPath returns the absolute path to the configuration file that was used. +// If absolute path cannot be determined, the relative path is returned. +func (m *Manager[T]) ConfigPath() string { + if m.viper.ConfigFileUsed() == "" { + return "" + } + + absPath, err := filepath.Abs(m.viper.ConfigFileUsed()) + if err != nil { + return m.viper.ConfigFileUsed() + } + + return absPath +} + +// Viper returns the viper instance used by the configuration manager. +func (m *Manager[T]) Viper() *viper.Viper { + return m.viper +} + +// Load reads and validates the configuration file, returning an error if any. +func (m *Manager[T]) Load() error { + // Make sure we have lock before checking if loaded + m.mu.Lock() + defer m.mu.Unlock() + + if m.loaded { + return nil // Already loaded, no need to load again + } + + return m.loadUnsafe() +} + +// loadUnsafe performs the actual loading without acquiring locks. Caller MUST +// hold the write lock. +func (m *Manager[T]) loadUnsafe() error { + // Initialize R to a new instance of T + m.R = new(T) + + // Run the pre-load function if it is set + if m.PreLoad != nil { + if err := m.PreLoad(m); err != nil { + return fmt.Errorf("pre-load function failed: %w", err) + } + } + + // Load the configuration file using viper + if err := m.viper.ReadInConfig(); err != nil { + return fmt.Errorf("viper failed to read config file: %w", err) + } + if err := m.viper.Unmarshal(m.R); err != nil { + return fmt.Errorf("viper failed to unmarshal config file: %w", err) + } + + // Validate the configuration schema using the validator package + validate := validator.New(validator.WithRequiredStructEnabled()) + if err := validate.Struct(m.R); err != nil { + return fmt.Errorf("validator failed to validate config file: %w", err) + } + + // Run the post-load function if it is set + if m.PostLoad != nil { + if err := m.PostLoad(m); err != nil { + return fmt.Errorf("post-load function failed: %w", err) + } + } + + m.loaded = true + return nil +} diff --git a/directory.go b/directory.go new file mode 100644 index 0000000..e769902 --- /dev/null +++ b/directory.go @@ -0,0 +1,52 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" +) + +// RootDir checks the current working directory and its parent directories for +// a given filename and returns the absolute path to the directory. If the file +// is not found within the specified depth or any other error occurs, it will +// panic. If depth is zero or negative, RootDir checks the current directory only. +func RootDir(searchName string, depth ...int) string { + // Get the current working directory + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + + // Apply default depth if not provided + depthVal := 0 + if len(depth) > 0 { + depthVal = depth[0] + } + + // Walk directories up to the specified depth to find the file + path := walkRootDir(searchName, cwd, depthVal) + if path == "" { + panic(fmt.Errorf("RootDir checked %d directories, no '%s' file found", depthVal+1, searchName)) + } + + // Try to get the absolute path of the found directory + abs, err := filepath.Abs(path) + if err != nil || abs == "" { + panic(fmt.Errorf("RootDir failed to get absolute path: %v", err)) + } + + return abs +} + +// walkRootDir recursively checks directories up to the specified reverseDepth. +func walkRootDir(searchName, path string, reverseDepth int) string { + if _, err := os.Stat(filepath.Join(path, searchName)); err == nil { + return path + } + + if reverseDepth > 0 { + return walkRootDir(searchName, path+"/..", reverseDepth-1) + } + + return "" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d91f619 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module gitea.auvem.com/go-toolkit/config + +go 1.24.0 + +require ( + github.com/go-playground/validator/v10 v10.26.0 + github.com/spf13/viper v1.20.1 +) + +require ( + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..161315c --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=