Add initial framework of configuration.

feat: configuration file system of app.

Note: still work in progress.
This commit is contained in:
2026-04-27 16:55:58 +08:00
parent e8a0c48658
commit 54f0deadbc
9 changed files with 515 additions and 40 deletions

74
internal/config/config.go Normal file
View File

@@ -0,0 +1,74 @@
package config
import (
"errors"
"fmt"
"net"
"time"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Storage StorageConfig `mapstructure:"storage"`
JWT JWTConfig `mapstructure:"jwt"`
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
Path string `mapstructure:"path"`
}
type StorageConfig struct {
Driver string `mapstructure:"driver"`
Local LocalStorageConfig `mapstructure:"local"`
}
type LocalStorageConfig struct {
Path string `mapstructure:"path"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessTTL time.Duration `mapstructure:"access_ttl"`
RefreshTTL time.Duration `mapstructure:"refresh_ttl"`
}
func (c *Config) Validate() error {
var errs []error
if c.Server.Port < 1 || c.Server.Port > 65535 {
errs = append(errs, fmt.Errorf("server.port: %d out of range [1, 65535]", c.Server.Port))
}
if addr := net.ParseIP(c.Server.Host); c.Server.Host != "" && addr == nil {
errs = append(errs, fmt.Errorf("server.host: %q is not a valid IP address", c.Server.Host))
}
if c.Database.Path == "" {
errs = append(errs, errors.New("database.path: must not be empty"))
}
if c.Storage.Local.Path == "" {
errs = append(errs, errors.New("storage.local.path: must not be empty"))
}
if c.JWT.Secret == "" {
errs = append(errs, errors.New("jwt.secret: must not be empty"))
}
if c.JWT.AccessTTL <= 0 {
errs = append(errs, fmt.Errorf("jwt.access_ttl: %v must be positive", c.JWT.AccessTTL))
}
if c.JWT.RefreshTTL <= 0 {
errs = append(errs, fmt.Errorf("jwt.refresh_ttl: %v must be positive", c.JWT.RefreshTTL))
}
return errors.Join(errs...)
}

66
internal/config/load.go Normal file
View File

@@ -0,0 +1,66 @@
package config
import (
"errors"
"fmt"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/viper"
)
func defaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 10086)
v.SetDefault("database.driver", "sqlite3")
v.SetDefault("database.path", "data/mygo.db")
v.SetDefault("storage.driver", "local")
v.SetDefault("storage.local.path", "data/files")
v.SetDefault("jwt.secret", "dev-secret-do-not-use-in-production")
v.SetDefault("jwt.access_ttl", "15m")
v.SetDefault("jwt.refresh_ttl", "168h")
}
func decodeHook() viper.DecoderConfigOption {
return viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc())
}
func New() *viper.Viper {
v := viper.New()
v.SetEnvPrefix("MYGO")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
defaults(v)
return v
}
func Load(v *viper.Viper, cfgFile string) (*Config, error) {
if cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
}
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
return nil, fmt.Errorf("read config: %w", err)
}
}
var cfg Config
if err := v.Unmarshal(&cfg, decodeHook()); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return &cfg, nil
}

View File

@@ -0,0 +1,197 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDefaults(t *testing.T) {
v := New()
cfg, err := Load(v, "")
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
got any
want any
}{
{"server.host", cfg.Server.Host, "0.0.0.0"},
{"server.port", cfg.Server.Port, 10086},
{"database.driver", cfg.Database.Driver, "sqlite3"},
{"database.path", cfg.Database.Path, "data/mygo.db"},
{"storage.driver", cfg.Storage.Driver, "local"},
{"storage.local.path", cfg.Storage.Local.Path, "data/files"},
{"jwt.access_ttl", cfg.JWT.AccessTTL, 15 * time.Minute},
{"jwt.refresh_ttl", cfg.JWT.RefreshTTL, 168 * time.Hour},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("got %v, want %v", tt.got, tt.want)
}
})
}
}
func TestFromYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
yaml := `
server:
host: 127.0.0.1
port: 9090
database:
driver: sqlite3
path: /tmp/mygo.db
storage:
driver: local
local:
path: /tmp/mygo-storage
jwt:
secret: test-secret
access_ttl: 30m
refresh_ttl: 72h
`
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
v := New()
cfg, err := Load(v, path)
if err != nil {
t.Fatal(err)
}
if cfg.Server.Host != "127.0.0.1" {
t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1")
}
if cfg.Server.Port != 9090 {
t.Errorf("server.port = %d, want %d", cfg.Server.Port, 9090)
}
if cfg.Database.Path != "/tmp/mygo.db" {
t.Errorf("database.path = %q, want %q", cfg.Database.Path, "/tmp/mygo.db")
}
if cfg.Storage.Local.Path != "/tmp/mygo-storage" {
t.Errorf("storage.local.path = %q, want %q", cfg.Storage.Local.Path, "/tmp/mygo-storage")
}
if cfg.JWT.Secret != "test-secret" {
t.Errorf("jwt.secret = %q, want %q", cfg.JWT.Secret, "test-secret")
}
if cfg.JWT.AccessTTL != 30*time.Minute {
t.Errorf("jwt.access_ttl = %v, want %v", cfg.JWT.AccessTTL, 30*time.Minute)
}
if cfg.JWT.RefreshTTL != 72*time.Hour {
t.Errorf("jwt.refresh_ttl = %v, want %v", cfg.JWT.RefreshTTL, 72*time.Hour)
}
}
func TestEnvOverride(t *testing.T) {
t.Setenv("MYGO_SERVER_PORT", "8080")
t.Setenv("MYGO_SERVER_HOST", "192.168.1.1")
t.Setenv("MYGO_JWT_SECRET", "env-secret")
t.Setenv("MYGO_DATABASE_PATH", "/env/path/db.sqlite")
v := New()
cfg, err := Load(v, "")
if err != nil {
t.Fatal(err)
}
if cfg.Server.Port != 8080 {
t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080)
}
if cfg.Server.Host != "192.168.1.1" {
t.Errorf("server.host = %q, want %q", cfg.Server.Host, "192.168.1.1")
}
if cfg.JWT.Secret != "env-secret" {
t.Errorf("jwt.secret = %q, want %q", cfg.JWT.Secret, "env-secret")
}
if cfg.Database.Path != "/env/path/db.sqlite" {
t.Errorf("database.path = %q, want %q", cfg.Database.Path, "/env/path/db.sqlite")
}
}
func TestEnvOverridesYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
yaml := `
server:
host: 0.0.0.0
port: 10086
`
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("MYGO_SERVER_PORT", "9999")
v := New()
cfg, err := Load(v, path)
if err != nil {
t.Fatal(err)
}
if cfg.Server.Port != 9999 {
t.Errorf("server.port = %d, want %d (env should override YAML)", cfg.Server.Port, 9999)
}
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("server.host = %q, want %q (YAML should be used when env is absent)", cfg.Server.Host, "0.0.0.0")
}
}
func TestValidatePortRange(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
yaml := `
server:
host: 0.0.0.0
port: 0
`
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
v := New()
_, err := Load(v, path)
if err == nil {
t.Fatal("expected error for port 0, got nil")
}
}
func TestValidateEmptySecret(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
yaml := `
jwt:
secret: ""
`
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
v := New()
_, err := Load(v, path)
if err == nil {
t.Fatal("expected error for empty jwt.secret, got nil")
}
}
func TestExplicitConfigFileNotFound(t *testing.T) {
v := New()
_, err := Load(v, "/nonexistent/path/config.yaml")
if err == nil {
t.Fatal("expected error when explicitly specifying a nonexistent config file")
}
}