Compare commits
5 Commits
tmdb/v0.9.
...
hws/v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 378bd8006d | |||
| e9b96fedb1 | |||
| da6ad0cf2e | |||
| 0ceeb37058 | |||
| f8919e8398 |
1
RULES.md
1
RULES.md
@@ -41,6 +41,7 @@ The wiki is located at ~/projects/golib-wiki and should be laid out as follows:
|
||||
Any changes to existing features or additional features implemented should have tests created and/or updated
|
||||
|
||||
3. Version control
|
||||
Do not make any changes to master. Checkout a branch to work on new features
|
||||
Version numbers are specified using git tags.
|
||||
Do not change version numbers. When updating documentation, append the branch name to the version number.
|
||||
Changes made to the golib-wiki repo should be made under the same branch name as the changes made in this repo
|
||||
|
||||
21
ezconf/LICENSE
Normal file
21
ezconf/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 haelnorr
|
||||
|
||||
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.
|
||||
161
ezconf/README.md
Normal file
161
ezconf/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# EZConf - v0.1.0
|
||||
|
||||
A unified configuration management system for loading and managing environment-based configurations across multiple packages in Go.
|
||||
|
||||
## Features
|
||||
|
||||
- Load configurations from multiple packages using their ConfigFromEnv functions
|
||||
- Parse package source code to extract environment variable documentation from struct comments
|
||||
- Generate and update .env files with all required environment variables
|
||||
- Print environment variable lists with descriptions and current values
|
||||
- Track additional custom environment variables
|
||||
- Support for both inline and doc comments in ENV format
|
||||
- Automatic environment variable value population
|
||||
- Preserve existing values when updating .env files
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.haelnorr.com/h/golib/ezconf
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Easy Integration (Recommended)
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.haelnorr.com/h/golib/ezconf"
|
||||
"git.haelnorr.com/h/golib/hlog"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/golib/hwsauth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new configuration loader
|
||||
loader := ezconf.New()
|
||||
|
||||
// Register packages using built-in integrations
|
||||
loader.RegisterIntegrations(
|
||||
hlog.NewEZConfIntegration(),
|
||||
hws.NewEZConfIntegration(),
|
||||
hwsauth.NewEZConfIntegration(),
|
||||
)
|
||||
|
||||
// Load all configurations
|
||||
if err := loader.Load(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Get configurations
|
||||
hlogCfg, _ := loader.GetConfig("hlog")
|
||||
cfg := hlogCfg.(*hlog.Config)
|
||||
|
||||
// Use configuration
|
||||
logger, _ := hlog.NewLogger(cfg, os.Stdout)
|
||||
logger.Info().Msg("Application started")
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Integration
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.haelnorr.com/h/golib/ezconf"
|
||||
"git.haelnorr.com/h/golib/hlog"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new configuration loader
|
||||
loader := ezconf.New()
|
||||
|
||||
// Add package paths to parse for ENV comments
|
||||
loader.AddPackagePath("vendor/git.haelnorr.com/h/golib/hlog")
|
||||
loader.AddPackagePath("vendor/git.haelnorr.com/h/golib/hws")
|
||||
|
||||
// Add configuration loaders
|
||||
loader.AddConfigFunc("hlog", func() (interface{}, error) {
|
||||
return hlog.ConfigFromEnv()
|
||||
})
|
||||
loader.AddConfigFunc("hws", func() (interface{}, error) {
|
||||
return hws.ConfigFromEnv()
|
||||
})
|
||||
|
||||
// Load all configurations
|
||||
if err := loader.Load(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Get a specific configuration
|
||||
hlogCfg, ok := loader.GetConfig("hlog")
|
||||
if ok {
|
||||
cfg := hlogCfg.(*hlog.Config)
|
||||
// Use configuration...
|
||||
}
|
||||
|
||||
// Print all environment variables
|
||||
if err := loader.PrintEnvVarsStdout(false); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Generate a .env file
|
||||
if err := loader.GenerateEnvFile(".env", false); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed documentation, see the [EZConf Wiki](../golib-wiki/EZConf.md).
|
||||
|
||||
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/ezconf).
|
||||
|
||||
## ENV Comment Format
|
||||
|
||||
EZConf parses struct field comments in the following format:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
// ENV LOG_LEVEL: Log level for the application (default: info)
|
||||
LogLevel string
|
||||
|
||||
// ENV DATABASE_URL: Database connection string (required)
|
||||
DatabaseURL string
|
||||
|
||||
// Inline comments also work
|
||||
Port int // ENV PORT: Server port (default: 8080)
|
||||
}
|
||||
```
|
||||
|
||||
The format is:
|
||||
- `ENV ENV_VAR_NAME: Description (optional modifiers)`
|
||||
- `(required)` or `(required if condition)` - marks variable as required
|
||||
- `(default: value)` - specifies default value
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging package with ConfigFromEnv
|
||||
- [hws](https://git.haelnorr.com/h/golib/hws) - HTTP web server with ConfigFromEnv
|
||||
- [hwsauth](https://git.haelnorr.com/h/golib/hwsauth) - Authentication middleware with ConfigFromEnv
|
||||
- [env](https://git.haelnorr.com/h/golib/env) - Environment variable helpers
|
||||
|
||||
120
ezconf/doc.go
Normal file
120
ezconf/doc.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Package ezconf provides a unified configuration management system for loading
|
||||
// and managing environment-based configurations across multiple packages.
|
||||
//
|
||||
// ezconf allows you to:
|
||||
// - Load configurations from multiple packages using their ConfigFromEnv functions
|
||||
// - Parse package source code to extract environment variable documentation
|
||||
// - Generate and update .env files with all required environment variables
|
||||
// - Print environment variable lists with descriptions and current values
|
||||
// - Track additional custom environment variables
|
||||
//
|
||||
// # Basic Usage
|
||||
//
|
||||
// Create a configuration loader and register packages using built-in integrations (recommended):
|
||||
//
|
||||
// import (
|
||||
// "git.haelnorr.com/h/golib/ezconf"
|
||||
// "git.haelnorr.com/h/golib/hlog"
|
||||
// "git.haelnorr.com/h/golib/hws"
|
||||
// "git.haelnorr.com/h/golib/hwsauth"
|
||||
// )
|
||||
//
|
||||
// loader := ezconf.New()
|
||||
//
|
||||
// // Register packages using built-in integrations
|
||||
// loader.RegisterIntegrations(
|
||||
// hlog.NewEZConfIntegration(),
|
||||
// hws.NewEZConfIntegration(),
|
||||
// hwsauth.NewEZConfIntegration(),
|
||||
// )
|
||||
//
|
||||
// // Load all configurations
|
||||
// if err := loader.Load(); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Get a specific configuration
|
||||
// hlogCfg, ok := loader.GetConfig("hlog")
|
||||
// if ok {
|
||||
// cfg := hlogCfg.(*hlog.Config)
|
||||
// // Use configuration...
|
||||
// }
|
||||
//
|
||||
// Alternatively, you can manually register packages:
|
||||
//
|
||||
// loader := ezconf.New()
|
||||
//
|
||||
// // Add package paths to parse for ENV comments
|
||||
// loader.AddPackagePath("/path/to/golib/hlog")
|
||||
//
|
||||
// // Add configuration loaders
|
||||
// loader.AddConfigFunc("hlog", func() (interface{}, error) {
|
||||
// return hlog.ConfigFromEnv()
|
||||
// })
|
||||
//
|
||||
// loader.Load()
|
||||
//
|
||||
// # Printing Environment Variables
|
||||
//
|
||||
// Print all environment variables with their descriptions:
|
||||
//
|
||||
// // Print without values (useful for documentation)
|
||||
// if err := loader.PrintEnvVarsStdout(false); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Print with current values
|
||||
// if err := loader.PrintEnvVarsStdout(true); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// # Generating .env Files
|
||||
//
|
||||
// Generate a new .env file with all environment variables:
|
||||
//
|
||||
// // Generate with default values
|
||||
// err := loader.GenerateEnvFile(".env", false)
|
||||
//
|
||||
// // Generate with current environment values
|
||||
// err := loader.GenerateEnvFile(".env", true)
|
||||
//
|
||||
// Update an existing .env file:
|
||||
//
|
||||
// // Update existing file, preserving existing values
|
||||
// err := loader.UpdateEnvFile(".env", true)
|
||||
//
|
||||
// # Adding Custom Environment Variables
|
||||
//
|
||||
// You can add additional environment variables that aren't in package configs:
|
||||
//
|
||||
// loader.AddEnvVar(ezconf.EnvVar{
|
||||
// Name: "DATABASE_URL",
|
||||
// Description: "PostgreSQL connection string",
|
||||
// Required: true,
|
||||
// Default: "postgres://localhost/mydb",
|
||||
// })
|
||||
//
|
||||
// # ENV Comment Format
|
||||
//
|
||||
// ezconf parses struct field comments in the following format:
|
||||
//
|
||||
// type Config struct {
|
||||
// // ENV LOG_LEVEL: Log level for the application (default: info)
|
||||
// LogLevel string
|
||||
//
|
||||
// // ENV DATABASE_URL: Database connection string (required)
|
||||
// DatabaseURL string
|
||||
// }
|
||||
//
|
||||
// The format is:
|
||||
// - ENV ENV_VAR_NAME: Description (optional modifiers)
|
||||
// - (required) or (required if condition) - marks variable as required
|
||||
// - (default: value) - specifies default value
|
||||
//
|
||||
// # Integration
|
||||
//
|
||||
// ezconf integrates with:
|
||||
// - All golib packages that follow the ConfigFromEnv pattern
|
||||
// - Any custom configuration structs with ENV comments
|
||||
// - Standard .env file format
|
||||
package ezconf
|
||||
149
ezconf/ezconf.go
Normal file
149
ezconf/ezconf.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package ezconf
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// EnvVar represents a single environment variable with its metadata
|
||||
type EnvVar struct {
|
||||
Name string // The environment variable name (e.g., "LOG_LEVEL")
|
||||
Description string // Description of what this variable does
|
||||
Required bool // Whether this variable is required
|
||||
Default string // Default value if not set
|
||||
CurrentValue string // Current value from environment (empty if not set)
|
||||
Group string // Group name for organizing variables (e.g., "Database", "Logging")
|
||||
}
|
||||
|
||||
// ConfigLoader manages configuration loading from multiple sources
|
||||
type ConfigLoader struct {
|
||||
configFuncs map[string]ConfigFunc // Map of config names to ConfigFromEnv functions
|
||||
packagePaths []string // Paths to packages to parse for ENV comments
|
||||
groupNames map[string]string // Map of package paths to group names
|
||||
extraEnvVars []EnvVar // Additional environment variables to track
|
||||
envVars []EnvVar // All extracted environment variables
|
||||
configs map[string]any // Loaded configurations
|
||||
}
|
||||
|
||||
// ConfigFunc is a function that loads configuration from environment variables
|
||||
type ConfigFunc func() (any, error)
|
||||
|
||||
// New creates a new ConfigLoader
|
||||
func New() *ConfigLoader {
|
||||
return &ConfigLoader{
|
||||
configFuncs: make(map[string]ConfigFunc),
|
||||
packagePaths: make([]string, 0),
|
||||
groupNames: make(map[string]string),
|
||||
extraEnvVars: make([]EnvVar, 0),
|
||||
envVars: make([]EnvVar, 0),
|
||||
configs: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// AddConfigFunc adds a ConfigFromEnv function to be called during loading.
|
||||
// The name parameter is used as a key to retrieve the loaded config later.
|
||||
func (cl *ConfigLoader) AddConfigFunc(name string, fn ConfigFunc) error {
|
||||
if fn == nil {
|
||||
return errors.New("config function cannot be nil")
|
||||
}
|
||||
if name == "" {
|
||||
return errors.New("config name cannot be empty")
|
||||
}
|
||||
cl.configFuncs[name] = fn
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPackagePath adds a package directory path to parse for ENV comments
|
||||
func (cl *ConfigLoader) AddPackagePath(path string) error {
|
||||
if path == "" {
|
||||
return errors.New("package path cannot be empty")
|
||||
}
|
||||
// Check if path exists
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return errors.Errorf("package path does not exist: %s", path)
|
||||
}
|
||||
cl.packagePaths = append(cl.packagePaths, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddEnvVar adds an additional environment variable to track
|
||||
func (cl *ConfigLoader) AddEnvVar(envVar EnvVar) {
|
||||
cl.extraEnvVars = append(cl.extraEnvVars, envVar)
|
||||
}
|
||||
|
||||
// ParseEnvVars extracts environment variables from packages and extra vars
|
||||
// This can be called without having actual environment variables set
|
||||
func (cl *ConfigLoader) ParseEnvVars() error {
|
||||
// Clear existing env vars to prevent duplicates
|
||||
cl.envVars = make([]EnvVar, 0)
|
||||
|
||||
// Parse packages for ENV comments
|
||||
for _, pkgPath := range cl.packagePaths {
|
||||
envVars, err := ParseConfigPackage(pkgPath)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to parse package: %s", pkgPath)
|
||||
}
|
||||
|
||||
// Set group name for these variables from stored mapping
|
||||
groupName := cl.groupNames[pkgPath]
|
||||
if groupName == "" {
|
||||
groupName = "Other"
|
||||
}
|
||||
|
||||
for i := range envVars {
|
||||
envVars[i].Group = groupName
|
||||
}
|
||||
|
||||
cl.envVars = append(cl.envVars, envVars...)
|
||||
}
|
||||
|
||||
// Add extra env vars
|
||||
cl.envVars = append(cl.envVars, cl.extraEnvVars...)
|
||||
|
||||
// Populate current values from environment
|
||||
for i := range cl.envVars {
|
||||
cl.envVars[i].CurrentValue = os.Getenv(cl.envVars[i].Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfigs executes the config functions to load actual configurations
|
||||
// This should be called after environment variables are properly set
|
||||
func (cl *ConfigLoader) LoadConfigs() error {
|
||||
// Load configurations
|
||||
for name, fn := range cl.configFuncs {
|
||||
cfg, err := fn()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to load config: %s", name)
|
||||
}
|
||||
cl.configs[name] = cfg
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load loads all configurations and extracts environment variables
|
||||
func (cl *ConfigLoader) Load() error {
|
||||
if err := cl.ParseEnvVars(); err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.LoadConfigs()
|
||||
}
|
||||
|
||||
// GetConfig returns a loaded configuration by name
|
||||
func (cl *ConfigLoader) GetConfig(name string) (any, bool) {
|
||||
cfg, ok := cl.configs[name]
|
||||
return cfg, ok
|
||||
}
|
||||
|
||||
// GetAllConfigs returns all loaded configurations
|
||||
func (cl *ConfigLoader) GetAllConfigs() map[string]any {
|
||||
return cl.configs
|
||||
}
|
||||
|
||||
// GetEnvVars returns all extracted environment variables
|
||||
func (cl *ConfigLoader) GetEnvVars() []EnvVar {
|
||||
return cl.envVars
|
||||
}
|
||||
488
ezconf/ezconf_test.go
Normal file
488
ezconf/ezconf_test.go
Normal file
@@ -0,0 +1,488 @@
|
||||
package ezconf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
loader := New()
|
||||
if loader == nil {
|
||||
t.Fatal("New() returned nil")
|
||||
}
|
||||
|
||||
if loader.configFuncs == nil {
|
||||
t.Error("configFuncs map is nil")
|
||||
}
|
||||
if loader.packagePaths == nil {
|
||||
t.Error("packagePaths slice is nil")
|
||||
}
|
||||
if loader.extraEnvVars == nil {
|
||||
t.Error("extraEnvVars slice is nil")
|
||||
}
|
||||
if loader.configs == nil {
|
||||
t.Error("configs map is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddConfigFunc(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
testFunc := func() (interface{}, error) {
|
||||
return "test config", nil
|
||||
}
|
||||
|
||||
err := loader.AddConfigFunc("test", testFunc)
|
||||
if err != nil {
|
||||
t.Errorf("AddConfigFunc failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loader.configFuncs) != 1 {
|
||||
t.Errorf("expected 1 config func, got %d", len(loader.configFuncs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddConfigFunc_NilFunction(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
err := loader.AddConfigFunc("test", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddConfigFunc_EmptyName(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
testFunc := func() (interface{}, error) {
|
||||
return "test config", nil
|
||||
}
|
||||
|
||||
err := loader.AddConfigFunc("", testFunc)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPackagePath(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
// Use current directory as test path
|
||||
err := loader.AddPackagePath(".")
|
||||
if err != nil {
|
||||
t.Errorf("AddPackagePath failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loader.packagePaths) != 1 {
|
||||
t.Errorf("expected 1 package path, got %d", len(loader.packagePaths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPackagePath_InvalidPath(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
err := loader.AddPackagePath("/nonexistent/path")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPackagePath_EmptyPath(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
err := loader.AddPackagePath("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEnvVar(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
envVar := EnvVar{
|
||||
Name: "TEST_VAR",
|
||||
Description: "Test variable",
|
||||
Required: true,
|
||||
Default: "default_value",
|
||||
}
|
||||
|
||||
loader.AddEnvVar(envVar)
|
||||
|
||||
if len(loader.extraEnvVars) != 1 {
|
||||
t.Errorf("expected 1 extra env var, got %d", len(loader.extraEnvVars))
|
||||
}
|
||||
|
||||
if loader.extraEnvVars[0].Name != "TEST_VAR" {
|
||||
t.Errorf("expected TEST_VAR, got %s", loader.extraEnvVars[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
// Add a test config function
|
||||
testCfg := struct {
|
||||
Value string
|
||||
}{Value: "test"}
|
||||
|
||||
loader.AddConfigFunc("test", func() (interface{}, error) {
|
||||
return testCfg, nil
|
||||
})
|
||||
|
||||
// Add current package path
|
||||
loader.AddPackagePath(".")
|
||||
|
||||
// Add an extra env var
|
||||
loader.AddEnvVar(EnvVar{
|
||||
Name: "EXTRA_VAR",
|
||||
Description: "Extra test variable",
|
||||
Default: "extra",
|
||||
})
|
||||
|
||||
err := loader.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that config was loaded
|
||||
cfg, ok := loader.GetConfig("test")
|
||||
if !ok {
|
||||
t.Error("test config not loaded")
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Error("test config is nil")
|
||||
}
|
||||
|
||||
// Check that env vars were extracted
|
||||
envVars := loader.GetEnvVars()
|
||||
if len(envVars) == 0 {
|
||||
t.Error("expected at least one env var")
|
||||
}
|
||||
|
||||
// Check for extra var
|
||||
foundExtra := false
|
||||
for _, ev := range envVars {
|
||||
if ev.Name == "EXTRA_VAR" {
|
||||
foundExtra = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundExtra {
|
||||
t.Error("extra env var not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ConfigFuncError(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
loader.AddConfigFunc("error", func() (interface{}, error) {
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
|
||||
err := loader.Load()
|
||||
if err == nil {
|
||||
t.Error("expected error from failing config func")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
testCfg := "test config"
|
||||
loader.configs["test"] = testCfg
|
||||
|
||||
cfg, ok := loader.GetConfig("test")
|
||||
if !ok {
|
||||
t.Error("expected to find test config")
|
||||
}
|
||||
if cfg != testCfg {
|
||||
t.Error("config value mismatch")
|
||||
}
|
||||
|
||||
// Test non-existent config
|
||||
_, ok = loader.GetConfig("nonexistent")
|
||||
if ok {
|
||||
t.Error("expected not to find nonexistent config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllConfigs(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
loader.configs["test1"] = "config1"
|
||||
loader.configs["test2"] = "config2"
|
||||
|
||||
allConfigs := loader.GetAllConfigs()
|
||||
if len(allConfigs) != 2 {
|
||||
t.Errorf("expected 2 configs, got %d", len(allConfigs))
|
||||
}
|
||||
|
||||
if allConfigs["test1"] != "config1" {
|
||||
t.Error("test1 config mismatch")
|
||||
}
|
||||
if allConfigs["test2"] != "config2" {
|
||||
t.Error("test2 config mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvVars(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
loader.envVars = []EnvVar{
|
||||
{Name: "VAR1", Description: "Variable 1"},
|
||||
{Name: "VAR2", Description: "Variable 2"},
|
||||
}
|
||||
|
||||
envVars := loader.GetEnvVars()
|
||||
if len(envVars) != 2 {
|
||||
t.Errorf("expected 2 env vars, got %d", len(envVars))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvVars(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
// Add a test config function
|
||||
loader.AddConfigFunc("test", func() (interface{}, error) {
|
||||
return "test config", nil
|
||||
})
|
||||
|
||||
// Add current package path
|
||||
loader.AddPackagePath(".")
|
||||
|
||||
// Add an extra env var
|
||||
loader.AddEnvVar(EnvVar{
|
||||
Name: "EXTRA_VAR",
|
||||
Description: "Extra test variable",
|
||||
Default: "extra",
|
||||
})
|
||||
|
||||
err := loader.ParseEnvVars()
|
||||
if err != nil {
|
||||
t.Fatalf("ParseEnvVars failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that env vars were extracted
|
||||
envVars := loader.GetEnvVars()
|
||||
if len(envVars) == 0 {
|
||||
t.Error("expected at least one env var")
|
||||
}
|
||||
|
||||
// Check for extra var
|
||||
foundExtra := false
|
||||
for _, ev := range envVars {
|
||||
if ev.Name == "EXTRA_VAR" {
|
||||
foundExtra = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundExtra {
|
||||
t.Error("extra env var not found")
|
||||
}
|
||||
|
||||
// Check that configs are NOT loaded (should be empty)
|
||||
configs := loader.GetAllConfigs()
|
||||
if len(configs) != 0 {
|
||||
t.Errorf("expected no configs loaded after ParseEnvVars, got %d", len(configs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigs(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
// Add a test config function
|
||||
testCfg := struct {
|
||||
Value string
|
||||
}{Value: "test"}
|
||||
|
||||
loader.AddConfigFunc("test", func() (interface{}, error) {
|
||||
return testCfg, nil
|
||||
})
|
||||
|
||||
// Manually set some env vars (simulating ParseEnvVars already called)
|
||||
loader.envVars = []EnvVar{
|
||||
{Name: "TEST_VAR", Description: "Test variable"},
|
||||
}
|
||||
|
||||
err := loader.LoadConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfigs failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that config was loaded
|
||||
cfg, ok := loader.GetConfig("test")
|
||||
if !ok {
|
||||
t.Error("test config not loaded")
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Error("test config is nil")
|
||||
}
|
||||
_ = cfg // Use the variable to avoid unused variable error
|
||||
|
||||
// Check that env vars are NOT modified (should remain as set)
|
||||
envVars := loader.GetEnvVars()
|
||||
if len(envVars) != 1 {
|
||||
t.Errorf("expected 1 env var, got %d", len(envVars))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigs_Error(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
loader.AddConfigFunc("error", func() (interface{}, error) {
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
|
||||
err := loader.LoadConfigs()
|
||||
if err == nil {
|
||||
t.Error("expected error from failing config func")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvVars_Then_LoadConfigs(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
// Add a test config function
|
||||
testCfg := struct {
|
||||
Value string
|
||||
}{Value: "test"}
|
||||
|
||||
loader.AddConfigFunc("test", func() (interface{}, error) {
|
||||
return testCfg, nil
|
||||
})
|
||||
|
||||
// Add current package path
|
||||
loader.AddPackagePath(".")
|
||||
|
||||
// Add an extra env var
|
||||
loader.AddEnvVar(EnvVar{
|
||||
Name: "EXTRA_VAR",
|
||||
Description: "Extra test variable",
|
||||
Default: "extra",
|
||||
})
|
||||
|
||||
// First parse env vars
|
||||
err := loader.ParseEnvVars()
|
||||
if err != nil {
|
||||
t.Fatalf("ParseEnvVars failed: %v", err)
|
||||
}
|
||||
|
||||
// Check env vars are extracted but configs are not loaded
|
||||
envVars := loader.GetEnvVars()
|
||||
if len(envVars) == 0 {
|
||||
t.Error("expected env vars to be extracted")
|
||||
}
|
||||
|
||||
configs := loader.GetAllConfigs()
|
||||
if len(configs) != 0 {
|
||||
t.Error("expected no configs loaded yet")
|
||||
}
|
||||
|
||||
// Then load configs
|
||||
err = loader.LoadConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfigs failed: %v", err)
|
||||
}
|
||||
|
||||
// Check both env vars and configs are loaded
|
||||
_, ok := loader.GetConfig("test")
|
||||
if !ok {
|
||||
t.Error("test config not loaded after LoadConfigs")
|
||||
}
|
||||
|
||||
configs = loader.GetAllConfigs()
|
||||
if len(configs) != 1 {
|
||||
t.Errorf("expected 1 config loaded, got %d", len(configs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Integration(t *testing.T) {
|
||||
// Integration test with real hlog package
|
||||
hlogPath := filepath.Join("..", "hlog")
|
||||
if _, err := os.Stat(hlogPath); os.IsNotExist(err) {
|
||||
t.Skip("hlog package not found, skipping integration test")
|
||||
}
|
||||
|
||||
loader := New()
|
||||
|
||||
// Add hlog package
|
||||
if err := loader.AddPackagePath(hlogPath); err != nil {
|
||||
t.Fatalf("failed to add hlog package: %v", err)
|
||||
}
|
||||
|
||||
// Load without config function (just parse)
|
||||
if err := loader.Load(); err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
envVars := loader.GetEnvVars()
|
||||
if len(envVars) == 0 {
|
||||
t.Error("expected env vars from hlog package")
|
||||
}
|
||||
|
||||
t.Logf("Found %d environment variables from hlog", len(envVars))
|
||||
for _, ev := range envVars {
|
||||
t.Logf(" %s: %s (default: %s, required: %t)", ev.Name, ev.Description, ev.Default, ev.Required)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvVars_GenerateEnvFile_Integration(t *testing.T) {
|
||||
// Test the new separated ParseEnvVars functionality
|
||||
hlogPath := filepath.Join("..", "hlog")
|
||||
if _, err := os.Stat(hlogPath); os.IsNotExist(err) {
|
||||
t.Skip("hlog package not found, skipping integration test")
|
||||
}
|
||||
|
||||
loader := New()
|
||||
|
||||
// Add hlog package
|
||||
if err := loader.AddPackagePath(hlogPath); err != nil {
|
||||
t.Fatalf("failed to add hlog package: %v", err)
|
||||
}
|
||||
|
||||
// Parse env vars without loading configs (this should work even if required env vars are missing)
|
||||
if err := loader.ParseEnvVars(); err != nil {
|
||||
t.Fatalf("ParseEnvVars failed: %v", err)
|
||||
}
|
||||
|
||||
envVars := loader.GetEnvVars()
|
||||
if len(envVars) == 0 {
|
||||
t.Error("expected env vars from hlog package")
|
||||
}
|
||||
|
||||
// Now test that we can generate an env file without calling Load()
|
||||
tempDir := t.TempDir()
|
||||
envFile := filepath.Join(tempDir, "test-generated.env")
|
||||
|
||||
err := loader.GenerateEnvFile(envFile, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEnvFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file was created and contains expected content
|
||||
content, err := os.ReadFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
if !strings.Contains(output, "# Environment Configuration") {
|
||||
t.Error("expected header in generated file")
|
||||
}
|
||||
|
||||
// Should contain environment variables from hlog
|
||||
foundHlogVar := false
|
||||
for _, ev := range envVars {
|
||||
if strings.Contains(output, ev.Name) {
|
||||
foundHlogVar = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundHlogVar {
|
||||
t.Error("expected to find at least one hlog environment variable in generated file")
|
||||
}
|
||||
|
||||
t.Logf("Successfully generated env file with %d variables", len(envVars))
|
||||
}
|
||||
5
ezconf/go.mod
Normal file
5
ezconf/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module git.haelnorr.com/h/golib/ezconf
|
||||
|
||||
go 1.23.4
|
||||
|
||||
require github.com/pkg/errors v0.9.1
|
||||
2
ezconf/go.sum
Normal file
2
ezconf/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
46
ezconf/integration.go
Normal file
46
ezconf/integration.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package ezconf
|
||||
|
||||
// Integration is an interface that packages can implement to provide
|
||||
// easy integration with ezconf
|
||||
type Integration interface {
|
||||
// Name returns the name to use when registering the config
|
||||
Name() string
|
||||
|
||||
// PackagePath returns the path to the package for source parsing
|
||||
PackagePath() string
|
||||
|
||||
// ConfigFunc returns the ConfigFromEnv function
|
||||
ConfigFunc() func() (interface{}, error)
|
||||
|
||||
// GroupName returns the display name for grouping environment variables
|
||||
GroupName() string
|
||||
}
|
||||
|
||||
// RegisterIntegration registers a package that implements the Integration interface
|
||||
func (cl *ConfigLoader) RegisterIntegration(integration Integration) error {
|
||||
// Add package path
|
||||
pkgPath := integration.PackagePath()
|
||||
if err := cl.AddPackagePath(pkgPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store group name for this package
|
||||
cl.groupNames[pkgPath] = integration.GroupName()
|
||||
|
||||
// Add config function
|
||||
if err := cl.AddConfigFunc(integration.Name(), integration.ConfigFunc()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterIntegrations registers multiple integrations at once
|
||||
func (cl *ConfigLoader) RegisterIntegrations(integrations ...Integration) error {
|
||||
for _, integration := range integrations {
|
||||
if err := cl.RegisterIntegration(integration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
212
ezconf/integration_test.go
Normal file
212
ezconf/integration_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package ezconf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Mock integration for testing
|
||||
type mockIntegration struct {
|
||||
name string
|
||||
packagePath string
|
||||
configFunc func() (interface{}, error)
|
||||
}
|
||||
|
||||
func (m mockIntegration) Name() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m mockIntegration) PackagePath() string {
|
||||
return m.packagePath
|
||||
}
|
||||
|
||||
func (m mockIntegration) ConfigFunc() func() (interface{}, error) {
|
||||
return m.configFunc
|
||||
}
|
||||
|
||||
func (m mockIntegration) GroupName() string {
|
||||
return "Test Group"
|
||||
}
|
||||
|
||||
func TestRegisterIntegration(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
integration := mockIntegration{
|
||||
name: "test",
|
||||
packagePath: ".",
|
||||
configFunc: func() (interface{}, error) {
|
||||
return "test config", nil
|
||||
},
|
||||
}
|
||||
|
||||
err := loader.RegisterIntegration(integration)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterIntegration failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify package path was added
|
||||
if len(loader.packagePaths) != 1 {
|
||||
t.Errorf("expected 1 package path, got %d", len(loader.packagePaths))
|
||||
}
|
||||
|
||||
// Verify config func was added
|
||||
if len(loader.configFuncs) != 1 {
|
||||
t.Errorf("expected 1 config func, got %d", len(loader.configFuncs))
|
||||
}
|
||||
|
||||
// Load and verify config
|
||||
if err := loader.Load(); err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
cfg, ok := loader.GetConfig("test")
|
||||
if !ok {
|
||||
t.Error("test config not found")
|
||||
}
|
||||
|
||||
if cfg != "test config" {
|
||||
t.Errorf("expected 'test config', got %v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterIntegration_InvalidPath(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
integration := mockIntegration{
|
||||
name: "test",
|
||||
packagePath: "/nonexistent/path",
|
||||
configFunc: func() (interface{}, error) {
|
||||
return "test config", nil
|
||||
},
|
||||
}
|
||||
|
||||
err := loader.RegisterIntegration(integration)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid package path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterIntegrations(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
integration1 := mockIntegration{
|
||||
name: "test1",
|
||||
packagePath: ".",
|
||||
configFunc: func() (interface{}, error) {
|
||||
return "config1", nil
|
||||
},
|
||||
}
|
||||
|
||||
integration2 := mockIntegration{
|
||||
name: "test2",
|
||||
packagePath: ".",
|
||||
configFunc: func() (interface{}, error) {
|
||||
return "config2", nil
|
||||
},
|
||||
}
|
||||
|
||||
err := loader.RegisterIntegrations(integration1, integration2)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterIntegrations failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loader.configFuncs) != 2 {
|
||||
t.Errorf("expected 2 config funcs, got %d", len(loader.configFuncs))
|
||||
}
|
||||
|
||||
// Load and verify configs
|
||||
if err := loader.Load(); err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
cfg1, ok1 := loader.GetConfig("test1")
|
||||
cfg2, ok2 := loader.GetConfig("test2")
|
||||
|
||||
if !ok1 || !ok2 {
|
||||
t.Error("configs not found")
|
||||
}
|
||||
|
||||
if cfg1 != "config1" || cfg2 != "config2" {
|
||||
t.Error("config values mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterIntegrations_PartialFailure(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
integration1 := mockIntegration{
|
||||
name: "test1",
|
||||
packagePath: ".",
|
||||
configFunc: func() (interface{}, error) {
|
||||
return "config1", nil
|
||||
},
|
||||
}
|
||||
|
||||
integration2 := mockIntegration{
|
||||
name: "test2",
|
||||
packagePath: "/nonexistent",
|
||||
configFunc: func() (interface{}, error) {
|
||||
return "config2", nil
|
||||
},
|
||||
}
|
||||
|
||||
err := loader.RegisterIntegrations(integration1, integration2)
|
||||
if err == nil {
|
||||
t.Error("expected error when one integration fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Interface(t *testing.T) {
|
||||
// Verify that mockIntegration implements Integration interface
|
||||
var _ Integration = (*mockIntegration)(nil)
|
||||
}
|
||||
|
||||
func TestRegisterIntegration_RealPackage(t *testing.T) {
|
||||
// Integration test with real hlog package if available
|
||||
hlogPath := filepath.Join("..", "hlog")
|
||||
if _, err := os.Stat(hlogPath); os.IsNotExist(err) {
|
||||
t.Skip("hlog package not found, skipping integration test")
|
||||
}
|
||||
|
||||
loader := New()
|
||||
|
||||
// Create a simple integration for testing
|
||||
integration := mockIntegration{
|
||||
name: "hlog",
|
||||
packagePath: hlogPath,
|
||||
configFunc: func() (interface{}, error) {
|
||||
// Return a mock config instead of calling real ConfigFromEnv
|
||||
return struct{ LogLevel string }{LogLevel: "info"}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := loader.RegisterIntegration(integration)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterIntegration with real package failed: %v", err)
|
||||
}
|
||||
|
||||
if err := loader.Load(); err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have parsed env vars from hlog
|
||||
envVars := loader.GetEnvVars()
|
||||
if len(envVars) == 0 {
|
||||
t.Error("expected env vars from hlog package")
|
||||
}
|
||||
|
||||
// Check for known hlog variables
|
||||
foundLogLevel := false
|
||||
for _, ev := range envVars {
|
||||
if ev.Name == "LOG_LEVEL" {
|
||||
foundLogLevel = true
|
||||
t.Logf("Found LOG_LEVEL: %s", ev.Description)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundLogLevel {
|
||||
t.Error("expected to find LOG_LEVEL from hlog")
|
||||
}
|
||||
}
|
||||
365
ezconf/output.go
Normal file
365
ezconf/output.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package ezconf
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// PrintEnvVars prints all environment variables to the provided writer
|
||||
func (cl *ConfigLoader) PrintEnvVars(w io.Writer, showValues bool) error {
|
||||
if len(cl.envVars) == 0 {
|
||||
return errors.New("no environment variables loaded (did you call Load()?)")
|
||||
}
|
||||
|
||||
// Group variables by their Group field
|
||||
groups := make(map[string][]EnvVar)
|
||||
groupOrder := make([]string, 0)
|
||||
|
||||
for _, envVar := range cl.envVars {
|
||||
group := envVar.Group
|
||||
if group == "" {
|
||||
group = "Other"
|
||||
}
|
||||
|
||||
if _, exists := groups[group]; !exists {
|
||||
groupOrder = append(groupOrder, group)
|
||||
}
|
||||
groups[group] = append(groups[group], envVar)
|
||||
}
|
||||
|
||||
// Print variables grouped by section
|
||||
for _, group := range groupOrder {
|
||||
vars := groups[group]
|
||||
|
||||
// Calculate max name length for alignment within this group
|
||||
maxNameLen := 0
|
||||
for _, envVar := range vars {
|
||||
nameLen := len(envVar.Name)
|
||||
if showValues {
|
||||
value := envVar.CurrentValue
|
||||
if value == "" && envVar.Default != "" {
|
||||
value = envVar.Default
|
||||
}
|
||||
nameLen += len(value) + 1 // +1 for the '=' sign
|
||||
}
|
||||
if nameLen > maxNameLen {
|
||||
maxNameLen = nameLen
|
||||
}
|
||||
}
|
||||
|
||||
// Print group header
|
||||
fmt.Fprintf(w, "\n%s Configuration\n", group)
|
||||
fmt.Fprintln(w, strings.Repeat("=", len(group)+14))
|
||||
fmt.Fprintln(w)
|
||||
|
||||
for _, envVar := range vars {
|
||||
// Build the variable line
|
||||
var varLine string
|
||||
if showValues {
|
||||
value := envVar.CurrentValue
|
||||
if value == "" && envVar.Default != "" {
|
||||
value = envVar.Default
|
||||
}
|
||||
varLine = fmt.Sprintf("%s=%s", envVar.Name, value)
|
||||
} else {
|
||||
varLine = envVar.Name
|
||||
}
|
||||
|
||||
// Calculate padding for alignment
|
||||
padding := maxNameLen - len(varLine) + 2
|
||||
|
||||
// Print with indentation and alignment
|
||||
fmt.Fprintf(w, " %s%s# %s", varLine, strings.Repeat(" ", padding), envVar.Description)
|
||||
|
||||
if envVar.Required {
|
||||
fmt.Fprint(w, " (required)")
|
||||
}
|
||||
if envVar.Default != "" {
|
||||
fmt.Fprintf(w, " (default: %s)", envVar.Default)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintEnvVarsStdout prints all environment variables to stdout
|
||||
func (cl *ConfigLoader) PrintEnvVarsStdout(showValues bool) error {
|
||||
return cl.PrintEnvVars(os.Stdout, showValues)
|
||||
}
|
||||
|
||||
// GenerateEnvFile creates a new .env file with all environment variables
|
||||
// If the file already exists, it will preserve any untracked variables
|
||||
func (cl *ConfigLoader) GenerateEnvFile(filename string, useCurrentValues bool) error {
|
||||
// Check if file exists and parse it to preserve untracked variables
|
||||
var existingUntracked []envFileLine
|
||||
if _, err := os.Stat(filename); err == nil {
|
||||
existingVars, err := parseEnvFile(filename)
|
||||
if err == nil {
|
||||
// Track which variables are managed by ezconf
|
||||
managedVars := make(map[string]bool)
|
||||
for _, envVar := range cl.envVars {
|
||||
managedVars[envVar.Name] = true
|
||||
}
|
||||
|
||||
// Collect untracked variables
|
||||
for _, line := range existingVars {
|
||||
if line.IsVar && !managedVars[line.Key] {
|
||||
existingUntracked = append(existingUntracked, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create env file")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
// Write header
|
||||
fmt.Fprintln(writer, "# Environment Configuration")
|
||||
fmt.Fprintln(writer, "# Generated by ezconf")
|
||||
fmt.Fprintln(writer, "#")
|
||||
fmt.Fprintln(writer, "# Variables marked as (required) must be set")
|
||||
fmt.Fprintln(writer, "# Variables with defaults can be left commented out to use the default value")
|
||||
|
||||
// Group variables by their Group field
|
||||
groups := make(map[string][]EnvVar)
|
||||
groupOrder := make([]string, 0)
|
||||
|
||||
for _, envVar := range cl.envVars {
|
||||
group := envVar.Group
|
||||
if group == "" {
|
||||
group = "Other"
|
||||
}
|
||||
|
||||
if _, exists := groups[group]; !exists {
|
||||
groupOrder = append(groupOrder, group)
|
||||
}
|
||||
groups[group] = append(groups[group], envVar)
|
||||
}
|
||||
|
||||
// Write variables grouped by section
|
||||
for _, group := range groupOrder {
|
||||
vars := groups[group]
|
||||
|
||||
// Print group header
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintf(writer, "# %s Configuration\n", group)
|
||||
fmt.Fprintln(writer, strings.Repeat("#", len(group)+15))
|
||||
|
||||
for _, envVar := range vars {
|
||||
// Write comment with description
|
||||
fmt.Fprintf(writer, "# %s", envVar.Description)
|
||||
if envVar.Required {
|
||||
fmt.Fprint(writer, " (required)")
|
||||
}
|
||||
if envVar.Default != "" {
|
||||
fmt.Fprintf(writer, " (default: %s)", envVar.Default)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
|
||||
// Get value to write
|
||||
value := ""
|
||||
if useCurrentValues && envVar.CurrentValue != "" {
|
||||
value = envVar.CurrentValue
|
||||
} else if envVar.Default != "" {
|
||||
value = envVar.Default
|
||||
}
|
||||
|
||||
// Comment out optional variables with defaults
|
||||
if !envVar.Required && envVar.Default != "" && (!useCurrentValues || envVar.CurrentValue == "") {
|
||||
fmt.Fprintf(writer, "# %s=%s\n", envVar.Name, value)
|
||||
} else {
|
||||
fmt.Fprintf(writer, "%s=%s\n", envVar.Name, value)
|
||||
}
|
||||
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
// Write untracked variables from existing file
|
||||
if len(existingUntracked) > 0 {
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintln(writer, "# Untracked Variables")
|
||||
fmt.Fprintln(writer, "# These variables were in the original file but are not managed by ezconf")
|
||||
fmt.Fprintln(writer, strings.Repeat("#", 72))
|
||||
fmt.Fprintln(writer)
|
||||
|
||||
for _, line := range existingUntracked {
|
||||
fmt.Fprintf(writer, "%s=%s\n", line.Key, line.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateEnvFile updates an existing .env file with new variables or updates existing ones
|
||||
func (cl *ConfigLoader) UpdateEnvFile(filename string, createIfNotExist bool) error {
|
||||
// Check if file exists
|
||||
_, err := os.Stat(filename)
|
||||
if os.IsNotExist(err) {
|
||||
if createIfNotExist {
|
||||
return cl.GenerateEnvFile(filename, false)
|
||||
}
|
||||
return errors.Errorf("env file does not exist: %s", filename)
|
||||
}
|
||||
|
||||
// Read existing file
|
||||
existingVars, err := parseEnvFile(filename)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to parse existing env file")
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
existingMap := make(map[string]string)
|
||||
for _, line := range existingVars {
|
||||
if line.IsVar {
|
||||
existingMap[line.Key] = line.Value
|
||||
}
|
||||
}
|
||||
|
||||
// Create new file with updates
|
||||
tempFile := filename + ".tmp"
|
||||
file, err := os.Create(tempFile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create temp file")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
// Track which variables we've written
|
||||
writtenVars := make(map[string]bool)
|
||||
|
||||
// Copy existing file, updating values as needed
|
||||
for _, line := range existingVars {
|
||||
if line.IsVar {
|
||||
// Check if we have this variable in our config
|
||||
found := false
|
||||
for _, envVar := range cl.envVars {
|
||||
if envVar.Name == line.Key {
|
||||
found = true
|
||||
// Keep existing value if it's set
|
||||
if line.Value != "" {
|
||||
fmt.Fprintf(writer, "%s=%s\n", line.Key, line.Value)
|
||||
} else {
|
||||
// Use default if available
|
||||
value := envVar.Default
|
||||
fmt.Fprintf(writer, "%s=%s\n", line.Key, value)
|
||||
}
|
||||
writtenVars[envVar.Name] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// Variable not in our config, keep it anyway
|
||||
fmt.Fprintf(writer, "%s=%s\n", line.Key, line.Value)
|
||||
}
|
||||
} else {
|
||||
// Comment or empty line, keep as-is
|
||||
fmt.Fprintln(writer, line.Line)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new variables that weren't in the file
|
||||
addedNew := false
|
||||
for _, envVar := range cl.envVars {
|
||||
if !writtenVars[envVar.Name] {
|
||||
if !addedNew {
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintln(writer, "# New variables added by ezconf")
|
||||
addedNew = true
|
||||
}
|
||||
|
||||
// Write comment with description
|
||||
fmt.Fprintf(writer, "# %s", envVar.Description)
|
||||
if envVar.Required {
|
||||
fmt.Fprint(writer, " (required)")
|
||||
}
|
||||
if envVar.Default != "" {
|
||||
fmt.Fprintf(writer, " (default: %s)", envVar.Default)
|
||||
}
|
||||
fmt.Fprintln(writer)
|
||||
|
||||
// Write variable with default value
|
||||
value := envVar.Default
|
||||
fmt.Fprintf(writer, "%s=%s\n", envVar.Name, value)
|
||||
fmt.Fprintln(writer)
|
||||
}
|
||||
}
|
||||
|
||||
writer.Flush()
|
||||
file.Close()
|
||||
|
||||
// Replace original file with updated one
|
||||
if err := os.Rename(tempFile, filename); err != nil {
|
||||
return errors.Wrap(err, "failed to replace env file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// envFileLine represents a line in an .env file
|
||||
type envFileLine struct {
|
||||
Line string // The full line
|
||||
IsVar bool // Whether this is a variable assignment
|
||||
Key string // Variable name (if IsVar is true)
|
||||
Value string // Variable value (if IsVar is true)
|
||||
}
|
||||
|
||||
// parseEnvFile parses an .env file and returns all lines
|
||||
func parseEnvFile(filename string) ([]envFileLine, error) {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open file")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
lines := make([]envFileLine, 0)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check if this is a variable assignment
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
lines = append(lines, envFileLine{
|
||||
Line: line,
|
||||
IsVar: true,
|
||||
Key: strings.TrimSpace(parts[0]),
|
||||
Value: strings.TrimSpace(parts[1]),
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Comment or empty line
|
||||
lines = append(lines, envFileLine{
|
||||
Line: line,
|
||||
IsVar: false,
|
||||
})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to scan file")
|
||||
}
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
405
ezconf/output_test.go
Normal file
405
ezconf/output_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package ezconf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrintEnvVars(t *testing.T) {
|
||||
loader := New()
|
||||
loader.envVars = []EnvVar{
|
||||
{
|
||||
Name: "LOG_LEVEL",
|
||||
Description: "Log level",
|
||||
Required: false,
|
||||
Default: "info",
|
||||
CurrentValue: "debug",
|
||||
},
|
||||
{
|
||||
Name: "DATABASE_URL",
|
||||
Description: "Database connection",
|
||||
Required: true,
|
||||
Default: "",
|
||||
CurrentValue: "postgres://localhost/db",
|
||||
},
|
||||
}
|
||||
|
||||
// Test without values
|
||||
t.Run("without values", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
err := loader.PrintEnvVars(buf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("PrintEnvVars failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "LOG_LEVEL") {
|
||||
t.Error("output should contain LOG_LEVEL")
|
||||
}
|
||||
if !strings.Contains(output, "Log level") {
|
||||
t.Error("output should contain description")
|
||||
}
|
||||
if !strings.Contains(output, "(default: info)") {
|
||||
t.Error("output should contain default value")
|
||||
}
|
||||
if strings.Contains(output, "debug") {
|
||||
t.Error("output should not contain current value when showValues is false")
|
||||
}
|
||||
})
|
||||
|
||||
// Test with values
|
||||
t.Run("with values", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
err := loader.PrintEnvVars(buf, true)
|
||||
if err != nil {
|
||||
t.Fatalf("PrintEnvVars failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "LOG_LEVEL=debug") {
|
||||
t.Error("output should contain LOG_LEVEL=debug")
|
||||
}
|
||||
if !strings.Contains(output, "DATABASE_URL=postgres://localhost/db") {
|
||||
t.Error("output should contain DATABASE_URL value")
|
||||
}
|
||||
if !strings.Contains(output, "(required)") {
|
||||
t.Error("output should indicate required variables")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateEnvFile(t *testing.T) {
|
||||
loader := New()
|
||||
loader.envVars = []EnvVar{
|
||||
{
|
||||
Name: "LOG_LEVEL",
|
||||
Description: "Log level",
|
||||
Required: false,
|
||||
Default: "info",
|
||||
CurrentValue: "debug",
|
||||
},
|
||||
{
|
||||
Name: "DATABASE_URL",
|
||||
Description: "Database connection",
|
||||
Required: true,
|
||||
Default: "postgres://localhost/db",
|
||||
CurrentValue: "",
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("generate with defaults", func(t *testing.T) {
|
||||
envFile := filepath.Join(tempDir, "test1.env")
|
||||
|
||||
err := loader.GenerateEnvFile(envFile, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEnvFile failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
if !strings.Contains(output, "LOG_LEVEL=info") {
|
||||
t.Error("expected default value for LOG_LEVEL")
|
||||
}
|
||||
if !strings.Contains(output, "# Log level") {
|
||||
t.Error("expected description comment")
|
||||
}
|
||||
if !strings.Contains(output, "# Database connection") {
|
||||
t.Error("expected DATABASE_URL description")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generate with current values", func(t *testing.T) {
|
||||
envFile := filepath.Join(tempDir, "test2.env")
|
||||
|
||||
err := loader.GenerateEnvFile(envFile, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEnvFile failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
if !strings.Contains(output, "LOG_LEVEL=debug") {
|
||||
t.Error("expected current value for LOG_LEVEL")
|
||||
}
|
||||
// DATABASE_URL has no current value, should use default
|
||||
if !strings.Contains(output, "DATABASE_URL=postgres://localhost/db") {
|
||||
t.Error("expected default value for DATABASE_URL when current is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserve untracked variables", func(t *testing.T) {
|
||||
envFile := filepath.Join(tempDir, "test3.env")
|
||||
|
||||
// Create existing file with untracked variable
|
||||
existing := `# Existing file
|
||||
LOG_LEVEL=warn
|
||||
CUSTOM_VAR=custom_value
|
||||
ANOTHER_VAR=another_value
|
||||
`
|
||||
if err := os.WriteFile(envFile, []byte(existing), 0644); err != nil {
|
||||
t.Fatalf("failed to create existing file: %v", err)
|
||||
}
|
||||
|
||||
// Generate new file - should preserve untracked variables
|
||||
err := loader.GenerateEnvFile(envFile, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEnvFile failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read generated file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
// Should have tracked variables with new format
|
||||
if !strings.Contains(output, "LOG_LEVEL") {
|
||||
t.Error("expected LOG_LEVEL to be present")
|
||||
}
|
||||
if !strings.Contains(output, "DATABASE_URL") {
|
||||
t.Error("expected DATABASE_URL to be present")
|
||||
}
|
||||
// Should preserve untracked variables
|
||||
if !strings.Contains(output, "CUSTOM_VAR=custom_value") {
|
||||
t.Error("expected to preserve CUSTOM_VAR")
|
||||
}
|
||||
if !strings.Contains(output, "ANOTHER_VAR=another_value") {
|
||||
t.Error("expected to preserve ANOTHER_VAR")
|
||||
}
|
||||
// Should have untracked section header
|
||||
if !strings.Contains(output, "Untracked Variables") {
|
||||
t.Error("expected untracked variables section header")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateEnvFile(t *testing.T) {
|
||||
loader := New()
|
||||
loader.envVars = []EnvVar{
|
||||
{
|
||||
Name: "LOG_LEVEL",
|
||||
Description: "Log level",
|
||||
Default: "info",
|
||||
},
|
||||
{
|
||||
Name: "NEW_VAR",
|
||||
Description: "New variable",
|
||||
Default: "new_default",
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("update existing file", func(t *testing.T) {
|
||||
envFile := filepath.Join(tempDir, "existing.env")
|
||||
|
||||
// Create existing file
|
||||
existing := `# Existing file
|
||||
LOG_LEVEL=debug
|
||||
OLD_VAR=old_value
|
||||
`
|
||||
if err := os.WriteFile(envFile, []byte(existing), 0644); err != nil {
|
||||
t.Fatalf("failed to create existing file: %v", err)
|
||||
}
|
||||
|
||||
err := loader.UpdateEnvFile(envFile, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateEnvFile failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read updated file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
// Should preserve existing value
|
||||
if !strings.Contains(output, "LOG_LEVEL=debug") {
|
||||
t.Error("expected to preserve existing LOG_LEVEL value")
|
||||
}
|
||||
// Should keep old variable
|
||||
if !strings.Contains(output, "OLD_VAR=old_value") {
|
||||
t.Error("expected to preserve OLD_VAR")
|
||||
}
|
||||
// Should add new variable
|
||||
if !strings.Contains(output, "NEW_VAR=new_default") {
|
||||
t.Error("expected to add NEW_VAR")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create if not exist", func(t *testing.T) {
|
||||
envFile := filepath.Join(tempDir, "new.env")
|
||||
|
||||
err := loader.UpdateEnvFile(envFile, true)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateEnvFile failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(envFile); os.IsNotExist(err) {
|
||||
t.Error("expected file to be created")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error if not exist and no create", func(t *testing.T) {
|
||||
envFile := filepath.Join(tempDir, "nonexistent.env")
|
||||
|
||||
err := loader.UpdateEnvFile(envFile, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseEnvFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
envFile := filepath.Join(tempDir, "test.env")
|
||||
|
||||
content := `# Comment line
|
||||
VAR1=value1
|
||||
VAR2=value2
|
||||
|
||||
# Another comment
|
||||
VAR3=value3
|
||||
EMPTY_VAR=
|
||||
`
|
||||
|
||||
if err := os.WriteFile(envFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
lines, err := parseEnvFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEnvFile failed: %v", err)
|
||||
}
|
||||
|
||||
varCount := 0
|
||||
for _, line := range lines {
|
||||
if line.IsVar {
|
||||
varCount++
|
||||
}
|
||||
}
|
||||
|
||||
if varCount != 4 {
|
||||
t.Errorf("expected 4 variables, got %d", varCount)
|
||||
}
|
||||
|
||||
// Check specific variables
|
||||
found := false
|
||||
for _, line := range lines {
|
||||
if line.IsVar && line.Key == "VAR1" && line.Value == "value1" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected to find VAR1=value1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_InvalidFile(t *testing.T) {
|
||||
_, err := parseEnvFile("/nonexistent/file.env")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintEnvVars_NoEnvVars(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
err := loader.PrintEnvVars(buf, false)
|
||||
if err == nil {
|
||||
t.Error("expected error when no env vars are loaded")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "did you call Load()") {
|
||||
t.Errorf("expected helpful error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintEnvVarsStdout(t *testing.T) {
|
||||
loader := New()
|
||||
loader.envVars = []EnvVar{
|
||||
{
|
||||
Name: "TEST_VAR",
|
||||
Description: "Test variable",
|
||||
Default: "test",
|
||||
},
|
||||
}
|
||||
|
||||
// This test just ensures it doesn't panic
|
||||
// We can't easily capture stdout in a unit test without redirecting it
|
||||
err := loader.PrintEnvVarsStdout(false)
|
||||
if err != nil {
|
||||
t.Errorf("PrintEnvVarsStdout(false) failed: %v", err)
|
||||
}
|
||||
|
||||
err = loader.PrintEnvVarsStdout(true)
|
||||
if err != nil {
|
||||
t.Errorf("PrintEnvVarsStdout(true) failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintEnvVarsStdout_NoEnvVars(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
err := loader.PrintEnvVarsStdout(false)
|
||||
if err == nil {
|
||||
t.Error("expected error when no env vars are loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintEnvVars_AfterParseEnvVars(t *testing.T) {
|
||||
loader := New()
|
||||
|
||||
// Add some env vars manually to simulate ParseEnvVars
|
||||
loader.envVars = []EnvVar{
|
||||
{
|
||||
Name: "LOG_LEVEL",
|
||||
Description: "Log level for the application",
|
||||
Required: false,
|
||||
Default: "info",
|
||||
CurrentValue: "",
|
||||
},
|
||||
{
|
||||
Name: "DATABASE_URL",
|
||||
Description: "Database connection string",
|
||||
Required: true,
|
||||
Default: "",
|
||||
CurrentValue: "",
|
||||
},
|
||||
}
|
||||
|
||||
// Test that PrintEnvVars works after ParseEnvVars (without Load)
|
||||
buf := &bytes.Buffer{}
|
||||
err := loader.PrintEnvVars(buf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("PrintEnvVars failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "LOG_LEVEL") {
|
||||
t.Error("output should contain LOG_LEVEL")
|
||||
}
|
||||
if !strings.Contains(output, "DATABASE_URL") {
|
||||
t.Error("output should contain DATABASE_URL")
|
||||
}
|
||||
if !strings.Contains(output, "(required)") {
|
||||
t.Error("output should indicate required variables")
|
||||
}
|
||||
if !strings.Contains(output, "(default: info)") {
|
||||
t.Error("output should contain default value")
|
||||
}
|
||||
}
|
||||
146
ezconf/parser.go
Normal file
146
ezconf/parser.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package ezconf
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ParseConfigFile parses a Go source file and extracts ENV comments from struct fields
|
||||
func ParseConfigFile(filename string) ([]EnvVar, error) {
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read file")
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, filename, content, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse file")
|
||||
}
|
||||
|
||||
envVars := make([]EnvVar, 0)
|
||||
|
||||
// Walk through the AST
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
// Look for struct type declarations
|
||||
typeSpec, ok := n.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
structType, ok := typeSpec.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Iterate through struct fields
|
||||
for _, field := range structType.Fields.List {
|
||||
var comment string
|
||||
|
||||
// Try to get from doc comment (comment before field)
|
||||
if field.Doc != nil && len(field.Doc.List) > 0 {
|
||||
comment = field.Doc.List[0].Text
|
||||
comment = strings.TrimPrefix(comment, "//")
|
||||
comment = strings.TrimSpace(comment)
|
||||
}
|
||||
|
||||
// Try to get from inline comment (comment after field)
|
||||
if comment == "" && field.Comment != nil && len(field.Comment.List) > 0 {
|
||||
comment = field.Comment.List[0].Text
|
||||
comment = strings.TrimPrefix(comment, "//")
|
||||
comment = strings.TrimSpace(comment)
|
||||
}
|
||||
|
||||
// Parse ENV comment
|
||||
if strings.HasPrefix(comment, "ENV ") {
|
||||
envVar, err := parseEnvComment(comment)
|
||||
if err == nil {
|
||||
envVars = append(envVars, *envVar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return envVars, nil
|
||||
}
|
||||
|
||||
// ParseConfigPackage parses all Go files in a package directory and extracts ENV comments
|
||||
func ParseConfigPackage(packagePath string) ([]EnvVar, error) {
|
||||
// Find all .go files in the package
|
||||
files, err := filepath.Glob(filepath.Join(packagePath, "*.go"))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to glob package files")
|
||||
}
|
||||
|
||||
allEnvVars := make([]EnvVar, 0)
|
||||
|
||||
for _, file := range files {
|
||||
// Skip test files
|
||||
if strings.HasSuffix(file, "_test.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
envVars, err := ParseConfigFile(file)
|
||||
if err != nil {
|
||||
// Log error but continue with other files
|
||||
continue
|
||||
}
|
||||
|
||||
allEnvVars = append(allEnvVars, envVars...)
|
||||
}
|
||||
|
||||
return allEnvVars, nil
|
||||
}
|
||||
|
||||
// parseEnvComment parses a field comment to extract environment variable information.
|
||||
// Expected format: ENV ENV_NAME: Description (required <condition>) (default: <value>)
|
||||
func parseEnvComment(comment string) (*EnvVar, error) {
|
||||
// Check if comment starts with ENV
|
||||
if !strings.HasPrefix(comment, "ENV ") {
|
||||
return nil, errors.New("comment does not start with 'ENV '")
|
||||
}
|
||||
|
||||
// Remove "ENV " prefix
|
||||
comment = strings.TrimPrefix(comment, "ENV ")
|
||||
|
||||
// Extract env var name (everything before the first colon)
|
||||
colonIdx := strings.Index(comment, ":")
|
||||
if colonIdx == -1 {
|
||||
return nil, errors.New("missing colon separator")
|
||||
}
|
||||
|
||||
envVar := &EnvVar{
|
||||
Name: strings.TrimSpace(comment[:colonIdx]),
|
||||
}
|
||||
|
||||
// Extract description and optional parts
|
||||
remainder := strings.TrimSpace(comment[colonIdx+1:])
|
||||
|
||||
// Check for (required ...) pattern
|
||||
requiredPattern := regexp.MustCompile(`\(required[^)]*\)`)
|
||||
if requiredPattern.MatchString(remainder) {
|
||||
envVar.Required = true
|
||||
remainder = requiredPattern.ReplaceAllString(remainder, "")
|
||||
}
|
||||
|
||||
// Check for (default: ...) pattern
|
||||
defaultPattern := regexp.MustCompile(`\(default:\s*([^)]*)\)`)
|
||||
if matches := defaultPattern.FindStringSubmatch(remainder); len(matches) > 1 {
|
||||
envVar.Default = strings.TrimSpace(matches[1])
|
||||
remainder = defaultPattern.ReplaceAllString(remainder, "")
|
||||
}
|
||||
|
||||
// What remains is the description
|
||||
envVar.Description = strings.TrimSpace(remainder)
|
||||
|
||||
return envVar, nil
|
||||
}
|
||||
202
ezconf/parser_test.go
Normal file
202
ezconf/parser_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package ezconf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEnvComment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
comment string
|
||||
wantEnvVar *EnvVar
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "simple env variable",
|
||||
comment: "ENV LOG_LEVEL: Log level for the application",
|
||||
wantEnvVar: &EnvVar{
|
||||
Name: "LOG_LEVEL",
|
||||
Description: "Log level for the application",
|
||||
Required: false,
|
||||
Default: "",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "env variable with default",
|
||||
comment: "ENV LOG_LEVEL: Log level for the application (default: info)",
|
||||
wantEnvVar: &EnvVar{
|
||||
Name: "LOG_LEVEL",
|
||||
Description: "Log level for the application",
|
||||
Required: false,
|
||||
Default: "info",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "required env variable",
|
||||
comment: "ENV DATABASE_URL: Database connection string (required)",
|
||||
wantEnvVar: &EnvVar{
|
||||
Name: "DATABASE_URL",
|
||||
Description: "Database connection string",
|
||||
Required: true,
|
||||
Default: "",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "required with condition and default",
|
||||
comment: "ENV LOG_DIR: Directory for log files (required when LOG_OUTPUT is file) (default: /var/log)",
|
||||
wantEnvVar: &EnvVar{
|
||||
Name: "LOG_DIR",
|
||||
Description: "Directory for log files",
|
||||
Required: true,
|
||||
Default: "/var/log",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "missing colon",
|
||||
comment: "ENV LOG_LEVEL Log level",
|
||||
wantEnvVar: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "not an ENV comment",
|
||||
comment: "This is a regular comment",
|
||||
wantEnvVar: nil,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
envVar, err := parseEnvComment(tt.comment)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if envVar.Name != tt.wantEnvVar.Name {
|
||||
t.Errorf("Name = %v, want %v", envVar.Name, tt.wantEnvVar.Name)
|
||||
}
|
||||
if envVar.Description != tt.wantEnvVar.Description {
|
||||
t.Errorf("Description = %v, want %v", envVar.Description, tt.wantEnvVar.Description)
|
||||
}
|
||||
if envVar.Required != tt.wantEnvVar.Required {
|
||||
t.Errorf("Required = %v, want %v", envVar.Required, tt.wantEnvVar.Required)
|
||||
}
|
||||
if envVar.Default != tt.wantEnvVar.Default {
|
||||
t.Errorf("Default = %v, want %v", envVar.Default, tt.wantEnvVar.Default)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigFile(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "config.go")
|
||||
|
||||
content := `package testpkg
|
||||
|
||||
type Config struct {
|
||||
// ENV LOG_LEVEL: Log level for the application (default: info)
|
||||
LogLevel string
|
||||
|
||||
// ENV LOG_OUTPUT: Output destination (default: console)
|
||||
LogOutput string
|
||||
|
||||
// ENV DATABASE_URL: Database connection string (required)
|
||||
DatabaseURL string
|
||||
}
|
||||
`
|
||||
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
envVars, err := ParseConfigFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigFile failed: %v", err)
|
||||
}
|
||||
|
||||
if len(envVars) != 3 {
|
||||
t.Errorf("expected 3 env vars, got %d", len(envVars))
|
||||
}
|
||||
|
||||
// Check first variable
|
||||
if envVars[0].Name != "LOG_LEVEL" {
|
||||
t.Errorf("expected LOG_LEVEL, got %s", envVars[0].Name)
|
||||
}
|
||||
if envVars[0].Default != "info" {
|
||||
t.Errorf("expected default 'info', got %s", envVars[0].Default)
|
||||
}
|
||||
|
||||
// Check required variable
|
||||
if envVars[2].Name != "DATABASE_URL" {
|
||||
t.Errorf("expected DATABASE_URL, got %s", envVars[2].Name)
|
||||
}
|
||||
if !envVars[2].Required {
|
||||
t.Error("expected DATABASE_URL to be required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigPackage(t *testing.T) {
|
||||
// Test with actual hlog package
|
||||
hlogPath := filepath.Join("..", "hlog")
|
||||
if _, err := os.Stat(hlogPath); os.IsNotExist(err) {
|
||||
t.Skip("hlog package not found, skipping integration test")
|
||||
}
|
||||
|
||||
envVars, err := ParseConfigPackage(hlogPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigPackage failed: %v", err)
|
||||
}
|
||||
|
||||
if len(envVars) == 0 {
|
||||
t.Error("expected at least one env var from hlog package")
|
||||
}
|
||||
|
||||
// Check for known hlog variables
|
||||
foundLogLevel := false
|
||||
for _, envVar := range envVars {
|
||||
if envVar.Name == "LOG_LEVEL" {
|
||||
foundLogLevel = true
|
||||
t.Logf("Found LOG_LEVEL: %s", envVar.Description)
|
||||
}
|
||||
}
|
||||
|
||||
if !foundLogLevel {
|
||||
t.Error("expected to find LOG_LEVEL in hlog package")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigFile_InvalidFile(t *testing.T) {
|
||||
_, err := ParseConfigFile("/nonexistent/file.go")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigPackage_InvalidPath(t *testing.T) {
|
||||
envVars, err := ParseConfigPackage("/nonexistent/package")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigPackage should not error on invalid path: %v", err)
|
||||
}
|
||||
|
||||
// Should return empty slice for invalid path
|
||||
if len(envVars) != 0 {
|
||||
t.Errorf("expected 0 env vars for invalid path, got %d", len(envVars))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# HLog - v0.10.3
|
||||
# HLog - v0.10.4
|
||||
|
||||
A structured logging package for Go built on top of [zerolog](https://github.com/rs/zerolog). HLog provides simple configuration via environment variables, flexible output options, and automatic log file management.
|
||||
|
||||
|
||||
35
hlog/ezconf.go
Normal file
35
hlog/ezconf.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package hlog
|
||||
|
||||
import "runtime"
|
||||
|
||||
// EZConfIntegration provides integration with ezconf for automatic configuration
|
||||
type EZConfIntegration struct{}
|
||||
|
||||
// PackagePath returns the path to the hlog package for source parsing
|
||||
func (e EZConfIntegration) PackagePath() string {
|
||||
_, filename, _, _ := runtime.Caller(0)
|
||||
// Return directory of this file
|
||||
return filename[:len(filename)-len("/ezconf.go")]
|
||||
}
|
||||
|
||||
// ConfigFunc returns the ConfigFromEnv function for ezconf
|
||||
func (e EZConfIntegration) ConfigFunc() func() (interface{}, error) {
|
||||
return func() (interface{}, error) {
|
||||
return ConfigFromEnv()
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the name to use when registering with ezconf
|
||||
func (e EZConfIntegration) Name() string {
|
||||
return "hlog"
|
||||
}
|
||||
|
||||
// GroupName returns the display name for grouping environment variables
|
||||
func (e EZConfIntegration) GroupName() string {
|
||||
return "HLog"
|
||||
}
|
||||
|
||||
// NewEZConfIntegration creates a new EZConf integration helper
|
||||
func NewEZConfIntegration() EZConfIntegration {
|
||||
return EZConfIntegration{}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# HWS (H Web Server) - v0.2.2
|
||||
# HWS (H Web Server) - v0.2.3
|
||||
|
||||
A lightweight, opinionated HTTP web server framework for Go built on top of the standard library's net/http.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
|
||||
// ErrorPageFunc is a function that returns an ErrorPage with the specified HTTP Status code
|
||||
// This will be called by the server when it needs to render an error page
|
||||
type ErrorPageFunc func(errorCode int) (ErrorPage, error)
|
||||
type ErrorPageFunc func(error HWSError) (ErrorPage, error)
|
||||
|
||||
// ErrorPage must implement a Render() function that takes in a context and ResponseWriter,
|
||||
// and should write a reponse as output to the ResponseWriter.
|
||||
@@ -40,11 +40,11 @@ type ErrorPage interface {
|
||||
Render(ctx context.Context, w io.Writer) error
|
||||
}
|
||||
|
||||
// TODO: add test for ErrorPageFunc that returns an error
|
||||
// AddErrorPage registers a handler that returns an ErrorPage
|
||||
func (server *Server) AddErrorPage(pageFunc ErrorPageFunc) error {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
page, err := pageFunc(http.StatusInternalServerError)
|
||||
page, err := pageFunc(HWSError{StatusCode: http.StatusInternalServerError})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "An error occured when trying to get the error page")
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (server *Server) ThrowError(w http.ResponseWriter, r *http.Request, error H
|
||||
}
|
||||
if error.RenderErrorPage {
|
||||
server.LogError(HWSError{Message: "Error page rendering", Error: nil, Level: ErrorDEBUG})
|
||||
errPage, err := server.errorPage(error.StatusCode)
|
||||
errPage, err := server.errorPage(error)
|
||||
if err != nil {
|
||||
server.LogError(HWSError{Message: "Failed to get a valid error page", Error: err})
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ import (
|
||||
type goodPage struct{}
|
||||
type badPage struct{}
|
||||
|
||||
func goodRender(code int) (hws.ErrorPage, error) {
|
||||
func goodRender(error hws.HWSError) (hws.ErrorPage, error) {
|
||||
return goodPage{}, nil
|
||||
}
|
||||
func badRender1(code int) (hws.ErrorPage, error) {
|
||||
func badRender1(error hws.HWSError) (hws.ErrorPage, error) {
|
||||
return badPage{}, nil
|
||||
}
|
||||
func badRender2(code int) (hws.ErrorPage, error) {
|
||||
func badRender2(error hws.HWSError) (hws.ErrorPage, error) {
|
||||
return nil, errors.New("I'm an error")
|
||||
}
|
||||
|
||||
|
||||
35
hws/ezconf.go
Normal file
35
hws/ezconf.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package hws
|
||||
|
||||
import "runtime"
|
||||
|
||||
// EZConfIntegration provides integration with ezconf for automatic configuration
|
||||
type EZConfIntegration struct{}
|
||||
|
||||
// PackagePath returns the path to the hws package for source parsing
|
||||
func (e EZConfIntegration) PackagePath() string {
|
||||
_, filename, _, _ := runtime.Caller(0)
|
||||
// Return directory of this file
|
||||
return filename[:len(filename)-len("/ezconf.go")]
|
||||
}
|
||||
|
||||
// ConfigFunc returns the ConfigFromEnv function for ezconf
|
||||
func (e EZConfIntegration) ConfigFunc() func() (interface{}, error) {
|
||||
return func() (interface{}, error) {
|
||||
return ConfigFromEnv()
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the name to use when registering with ezconf
|
||||
func (e EZConfIntegration) Name() string {
|
||||
return "hws"
|
||||
}
|
||||
|
||||
// GroupName returns the display name for grouping environment variables
|
||||
func (e EZConfIntegration) GroupName() string {
|
||||
return "HWS"
|
||||
}
|
||||
|
||||
// NewEZConfIntegration creates a new EZConf integration helper
|
||||
func NewEZConfIntegration() EZConfIntegration {
|
||||
return EZConfIntegration{}
|
||||
}
|
||||
@@ -149,7 +149,8 @@ func Test_Start_Errors(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = server.Start(t.Context())
|
||||
var nilCtx context.Context = nil
|
||||
err = server.Start(nilCtx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Context cannot be nil")
|
||||
})
|
||||
@@ -163,7 +164,8 @@ func Test_Shutdown_Errors(t *testing.T) {
|
||||
startTestServer(t, server)
|
||||
<-server.Ready()
|
||||
|
||||
err := server.Shutdown(t.Context())
|
||||
var nilCtx context.Context = nil
|
||||
err := server.Shutdown(nilCtx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Context cannot be nil")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# HWSAuth - v0.3.3
|
||||
# HWSAuth - v0.3.4
|
||||
|
||||
JWT-based authentication middleware for the HWS web framework.
|
||||
|
||||
|
||||
35
hwsauth/ezconf.go
Normal file
35
hwsauth/ezconf.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package hwsauth
|
||||
|
||||
import "runtime"
|
||||
|
||||
// EZConfIntegration provides integration with ezconf for automatic configuration
|
||||
type EZConfIntegration struct{}
|
||||
|
||||
// PackagePath returns the path to the hwsauth package for source parsing
|
||||
func (e EZConfIntegration) PackagePath() string {
|
||||
_, filename, _, _ := runtime.Caller(0)
|
||||
// Return directory of this file
|
||||
return filename[:len(filename)-len("/ezconf.go")]
|
||||
}
|
||||
|
||||
// ConfigFunc returns the ConfigFromEnv function for ezconf
|
||||
func (e EZConfIntegration) ConfigFunc() func() (interface{}, error) {
|
||||
return func() (interface{}, error) {
|
||||
return ConfigFromEnv()
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the name to use when registering with ezconf
|
||||
func (e EZConfIntegration) Name() string {
|
||||
return "hwsauth"
|
||||
}
|
||||
|
||||
// GroupName returns the display name for grouping environment variables
|
||||
func (e EZConfIntegration) GroupName() string {
|
||||
return "HWSAuth"
|
||||
}
|
||||
|
||||
// NewEZConfIntegration creates a new EZConf integration helper
|
||||
func NewEZConfIntegration() EZConfIntegration {
|
||||
return EZConfIntegration{}
|
||||
}
|
||||
21
tmdb/LICENSE
Normal file
21
tmdb/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 haelnorr
|
||||
|
||||
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.
|
||||
239
tmdb/README.md
Normal file
239
tmdb/README.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# TMDB - v0.9.2
|
||||
|
||||
A Go client library for The Movie Database (TMDB) API with automatic rate limiting, retry logic, and convenient helper functions.
|
||||
|
||||
## Features
|
||||
|
||||
- Clean interface for TMDB's REST API
|
||||
- Automatic rate limiting with exponential backoff
|
||||
- Retry logic for rate limit errors (respects Retry-After header)
|
||||
- Movie search functionality
|
||||
- Movie details retrieval
|
||||
- Cast and crew information
|
||||
- Image URL helpers
|
||||
- Environment variable configuration with ConfigFromEnv
|
||||
- EZConf integration for unified configuration
|
||||
- Comprehensive test coverage (94.1%)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.haelnorr.com/h/golib/tmdb
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"git.haelnorr.com/h/golib/tmdb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create API connection
|
||||
api, err := tmdb.NewAPIConnection()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Search for a movie
|
||||
results, err := api.SearchMovies("Fight Club", false, 1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, movie := range results.Results {
|
||||
fmt.Printf("%s (%s)\n", movie.Title, movie.ReleaseYear())
|
||||
fmt.Printf("Poster: %s\n", movie.GetPoster(&api.Image, "w500"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Getting Movie Details
|
||||
|
||||
```go
|
||||
// Get detailed information about a movie
|
||||
movie, err := api.GetMovie(550) // Fight Club
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Title: %s\n", movie.Title)
|
||||
fmt.Printf("Overview: %s\n", movie.Overview)
|
||||
fmt.Printf("Release Date: %s\n", movie.ReleaseDate)
|
||||
fmt.Printf("IMDb ID: %s\n", movie.IMDbID)
|
||||
fmt.Printf("Rating: %.1f/10\n", movie.VoteAverage)
|
||||
```
|
||||
|
||||
### Getting Cast and Crew
|
||||
|
||||
```go
|
||||
// Get credits for a movie
|
||||
credits, err := api.GetCredits(550)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Cast:")
|
||||
for _, actor := range credits.Cast {
|
||||
fmt.Printf(" %s as %s\n", actor.Name, actor.Character)
|
||||
}
|
||||
|
||||
fmt.Println("\nDirector:")
|
||||
for _, member := range credits.Crew {
|
||||
if member.Job == "Director" {
|
||||
fmt.Printf(" %s\n", member.Name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The package requires the following environment variable:
|
||||
|
||||
```bash
|
||||
# TMDB API access token (required)
|
||||
TMDB_TOKEN=your_api_token_here
|
||||
```
|
||||
|
||||
Get your API token from: https://www.themoviedb.org/settings/api
|
||||
|
||||
### Using EZConf Integration
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.haelnorr.com/h/golib/ezconf"
|
||||
"git.haelnorr.com/h/golib/tmdb"
|
||||
)
|
||||
|
||||
loader := ezconf.New()
|
||||
loader.RegisterIntegration(tmdb.NewEZConfIntegration())
|
||||
loader.Load()
|
||||
|
||||
// Get the configured API connection
|
||||
api, ok := loader.GetConfig("tmdb")
|
||||
if !ok {
|
||||
log.Fatal("tmdb config not found")
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
TMDB has rate limits around 40 requests per second. This package implements automatic retry logic with exponential backoff:
|
||||
|
||||
- **Initial backoff**: 1 second
|
||||
- **Exponential growth**: 1s → 2s → 4s → 8s → 16s → 32s (max)
|
||||
- **Maximum retries**: 3 attempts
|
||||
- **Respects** Retry-After header when provided by the API
|
||||
|
||||
All API calls automatically handle rate limiting, so you don't need to worry about it.
|
||||
|
||||
## Image URLs
|
||||
|
||||
The TMDB API provides base URLs for images. Use helper methods to construct full image URLs:
|
||||
|
||||
```go
|
||||
// Available poster sizes: "w92", "w154", "w185", "w342", "w500", "w780", "original"
|
||||
posterURL := movie.GetPoster(&api.Image, "w500")
|
||||
|
||||
// Available backdrop sizes: "w300", "w780", "w1280", "original"
|
||||
backdropURL := movie.GetBackdrop(&api.Image, "w1280")
|
||||
|
||||
// Available profile sizes: "w45", "w185", "h632", "original"
|
||||
profileURL := actor.GetProfile(&api.Image, "w185")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Main Functions
|
||||
|
||||
- `NewAPIConnection() (*APIConnection, error)` - Create a new API connection
|
||||
- `SearchMovies(query string, includeAdult bool, page int) (*SearchResponse, error)` - Search for movies
|
||||
- `GetMovie(movieID int) (*Movie, error)` - Get detailed movie information
|
||||
- `GetCredits(movieID int) (*Credits, error)` - Get cast and crew information
|
||||
|
||||
### Helper Methods
|
||||
|
||||
**Movie Methods:**
|
||||
- `ReleaseYear() string` - Extract year from release date
|
||||
- `GetPoster(imgConfig *ImageConfig, size string) string` - Get full poster URL
|
||||
- `GetBackdrop(imgConfig *ImageConfig, size string) string` - Get full backdrop URL
|
||||
|
||||
**Cast/Crew Methods:**
|
||||
- `GetProfile(imgConfig *ImageConfig, size string) string` - Get full profile image URL
|
||||
|
||||
## Error Handling
|
||||
|
||||
The package returns wrapped errors for easy debugging:
|
||||
|
||||
```go
|
||||
data, err := api.SearchMovies("Inception", false, 1)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "rate limit exceeded") {
|
||||
// Handle rate limiting
|
||||
} else if strings.Contains(err.Error(), "unexpected status code: 401") {
|
||||
// Invalid API token
|
||||
} else if strings.Contains(err.Error(), "unexpected status code: 404") {
|
||||
// Resource not found
|
||||
} else {
|
||||
// Network or other errors
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed documentation, see the [TMDB Wiki](https://git.haelnorr.com/h/golib/wiki/TMDB.md).
|
||||
|
||||
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/tmdb).
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite (requires a valid TMDB_TOKEN environment variable):
|
||||
|
||||
```bash
|
||||
export TMDB_TOKEN=your_api_token_here
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
Current test coverage: 94.1%
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Reuse API connections** - Create one connection and reuse it for multiple requests
|
||||
2. **Cache responses** - Cache API responses when appropriate to reduce API calls
|
||||
3. **Use specific image sizes** - Use appropriate image sizes instead of "original" to save bandwidth
|
||||
4. **Handle rate limits gracefully** - The library handles this automatically, but be aware it may introduce delays
|
||||
5. **Set a timeout** - Consider using context with timeout for long-running operations
|
||||
|
||||
## Example Projects
|
||||
|
||||
Check out these projects using the TMDB library:
|
||||
|
||||
- [Project ReShoot](https://git.haelnorr.com/h/reshoot) - Movie database application
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [ezconf](https://git.haelnorr.com/h/golib/ezconf) - Unified configuration management
|
||||
- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging with zerolog
|
||||
|
||||
## External Resources
|
||||
|
||||
- [TMDB API Documentation](https://developer.themoviedb.org/docs)
|
||||
- [Get API Token](https://www.themoviedb.org/settings/api)
|
||||
- [TMDB Website](https://www.themoviedb.org/)
|
||||
36
tmdb/ezconf.go
Normal file
36
tmdb/ezconf.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package tmdb
|
||||
|
||||
import "runtime"
|
||||
|
||||
// EZConfIntegration provides integration with ezconf for automatic configuration
|
||||
type EZConfIntegration struct{}
|
||||
|
||||
// PackagePath returns the path to the tmdb package for source parsing
|
||||
func (e EZConfIntegration) PackagePath() string {
|
||||
_, filename, _, _ := runtime.Caller(0)
|
||||
// Return directory of this file
|
||||
return filename[:len(filename)-len("/ezconf.go")]
|
||||
}
|
||||
|
||||
// ConfigFunc returns the NewAPIConnection function for ezconf
|
||||
// Note: tmdb uses NewAPIConnection instead of ConfigFromEnv
|
||||
func (e EZConfIntegration) ConfigFunc() func() (interface{}, error) {
|
||||
return func() (interface{}, error) {
|
||||
return NewAPIConnection()
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the name to use when registering with ezconf
|
||||
func (e EZConfIntegration) Name() string {
|
||||
return "tmdb"
|
||||
}
|
||||
|
||||
// GroupName returns the display name for grouping environment variables
|
||||
func (e EZConfIntegration) GroupName() string {
|
||||
return "TMDB"
|
||||
}
|
||||
|
||||
// NewEZConfIntegration creates a new EZConf integration helper
|
||||
func NewEZConfIntegration() EZConfIntegration {
|
||||
return EZConfIntegration{}
|
||||
}
|
||||
Reference in New Issue
Block a user