diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 3cc9a07..8742ee2 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -17,14 +17,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Fetch Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '1.24.x' + go-version: '1.25.x' - name: golangci-lint - uses: golangci/golangci-lint-action@v8 + uses: golangci/golangci-lint-action@v9 with: - version: v2.1.6 + version: v2.7.2 diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 742133d..30753af 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Fetch Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' - name: Install pre-commit uses: pre-commit/action@v3.0.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 85a80ab..e354561 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-case-conflict - id: check-json diff --git a/Makefile b/Makefile index 61780df..77516cf 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ endef GOTESTSUM := go run gotest.tools/gotestsum@latest -f testname -- ./... -race -count=1 TESTFLAGS := -shuffle=on COVERFLAGS := -covermode=atomic -GOLANGCI_LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 +GOLANGCI_LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 check-pre-commit: ifeq (, $(shell which pre-commit)) diff --git a/config/config.go b/config/config.go index 73d94f1..d034663 100644 --- a/config/config.go +++ b/config/config.go @@ -11,96 +11,22 @@ import ( ) const ( - DefaultAppName = "app" - DefaultEnvName = "local" - AppName = "app-name" - EnvName = "env-name" + AppName = "app-name" + EnvName = "env-name" ) -// Config holds all global configuration for the application. +// Config holds the application configuration. type Config struct { AppName string EnvName string } -// ConfigLoader manages the configuration loading process. -type ConfigLoader struct { - envVarPrefix string - configPaths []string - configType string +var defaultConfig = &Config{ + AppName: "app", + EnvName: "local", } -// Option defines a function type for configuring ConfigLoader. -type Option func(*ConfigLoader) - -// LoadOption defines a function type for configuring the load process. -type LoadOption func(*loadOptions) - -// loadOptions holds configuration for the loading process. -type loadOptions struct { - loadConfigFile bool - configFileName string -} - -// WithEnvPrefix sets the environment variable prefix. -func WithEnvPrefix(prefix string) Option { - return func(cl *ConfigLoader) { - cl.envVarPrefix = prefix - } -} - -// WithConfigPaths sets the configuration file search paths. -func WithConfigPaths(paths ...string) Option { - return func(cl *ConfigLoader) { - cl.configPaths = paths - } -} - -// WithConfigType sets the configuration file type. -func WithConfigType(configType string) Option { - return func(cl *ConfigLoader) { - cl.configType = configType - } -} - -// WithConfigFile enables loading from config file. -func WithConfigFile(enabled bool) LoadOption { - return func(o *loadOptions) { - o.loadConfigFile = enabled - } -} - -// WithCustomConfigFile enables loading from a custom config file name. -func WithCustomConfigFile(fileName string) LoadOption { - return func(o *loadOptions) { - o.loadConfigFile = true - o.configFileName = fileName - } -} - -// NewConfigLoader creates a new ConfigLoader with the given options. -func NewConfigLoader(options ...Option) *ConfigLoader { - cl := &ConfigLoader{ - envVarPrefix: "", - configPaths: []string{"./configs", "/configs"}, - configType: "toml", - } - - for _, option := range options { - option(cl) - } - - return cl -} - -// AddConfigPath adds a configuration file search path. -func (cl *ConfigLoader) AddConfigPath(path string) *ConfigLoader { - cl.configPaths = append(cl.configPaths, path) - return cl -} - -// Validate checks if the configuration values are valid. -func (c *Config) Validate() error { +func (c *Config) validate() error { if strings.TrimSpace(c.AppName) == "" { return errors.New("application name cannot be empty") } @@ -110,77 +36,76 @@ func (c *Config) Validate() error { return nil } -// bindFlags adds all the flags to the provided flag set. -func (cl *ConfigLoader) bindFlags(fs *pflag.FlagSet, config *Config) { - fs.StringVar(&config.AppName, AppName, config.AppName, "The name of the application.") - fs.StringVar(&config.EnvName, EnvName, config.EnvName, - "The environment of the application. Used to load the right config file.") +type options struct { + appName *string + envName *string + configPaths []string + configType string } -// normalizeFlags changes all flags that contain "_" separators to use "-". -func normalizeFlags(f *pflag.FlagSet, name string) pflag.NormalizedName { - if strings.Contains(name, "_") { - return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-")) +// Option configures Config creation. +type Option func(*options) + +// WithAppName sets the application name. +func WithAppName(name string) Option { + return func(o *options) { + o.appName = &name } - return pflag.NormalizedName(name) } -// setupViper configures viper with environment variable settings. -func (cl *ConfigLoader) setupViper() { - if cl.envVarPrefix != "" { - viper.SetEnvPrefix(cl.envVarPrefix) +// WithEnvName sets the environment name. +func WithEnvName(env string) Option { + return func(o *options) { + o.envName = &env } - replacer := strings.NewReplacer("-", "_") - viper.SetEnvKeyReplacer(replacer) - viper.AutomaticEnv() } -// loadConfigFile loads the configuration file using viper. -func (cl *ConfigLoader) loadConfigFile(configName string) error { - viper.SetConfigName(configName) - viper.SetConfigType(cl.configType) - - for _, path := range cl.configPaths { - viper.AddConfigPath(path) +// WithConfigPaths sets paths to search for config files. +func WithConfigPaths(paths ...string) Option { + return func(o *options) { + o.configPaths = paths } +} - if err := viper.ReadInConfig(); err != nil { - var configFileNotFoundError viper.ConfigFileNotFoundError - if errors.As(err, &configFileNotFoundError) { - return fmt.Errorf("config file '%s.%s' not found in paths %v: %w", - configName, cl.configType, cl.configPaths, err) - } - return fmt.Errorf("failed to read config file: %w", err) +// WithConfigType sets the config file type (toml, yaml, json, etc). +func WithConfigType(typ string) Option { + return func(o *options) { + o.configType = typ } - - return nil } -// LoadConfig loads configuration with the specified options. -func (cl *ConfigLoader) LoadConfig(options []LoadOption, flagSets ...func(fs *pflag.FlagSet)) (*Config, error) { - opts := &loadOptions{ - loadConfigFile: false, - configFileName: "", - } +// New creates a new Config. +// It registers app-name and env-name flags on fs, parses fs, then reads from options/flags/env/file. +// Environment variables are prefixed with the uppercased app name (e.g. MYAPP_PORT for app "myapp"). +// Precedence: flags > environment variables > config file (if paths+type provided) > options > defaults. +func New(fs *pflag.FlagSet, opts ...Option) (*Config, error) { + cfg := *defaultConfig - for _, option := range options { - option(opts) + o := &options{} + for _, opt := range opts { + opt(o) } - fs := pflag.NewFlagSet("config", pflag.ExitOnError) + optionAppNameSet := o.appName != nil + optionEnvNameSet := o.envName != nil - config := &Config{ - AppName: DefaultAppName, - EnvName: DefaultEnvName, + if o.appName != nil { + cfg.AppName = *o.appName } - - cl.bindFlags(fs, config) - - for _, flagSet := range flagSets { - flagSet(fs) + if o.envName != nil { + cfg.EnvName = *o.envName } - fs.SetNormalizeFunc(normalizeFlags) + fs.String( + AppName, + cfg.AppName, + "The name of the application.", + ) + fs.String( + EnvName, + cfg.EnvName, + "The environment of the application. Used to load the right config file.", + ) if err := fs.Parse(os.Args[1:]); err != nil { return nil, fmt.Errorf("failed to parse flags: %w", err) @@ -190,26 +115,66 @@ func (cl *ConfigLoader) LoadConfig(options []LoadOption, flagSets ...func(fs *pf return nil, fmt.Errorf("failed to bind flags to viper: %w", err) } - cl.setupViper() + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() - if opts.loadConfigFile { - configName := opts.configFileName - if configName == "" { - envName := viper.GetString(EnvName) - configName = fmt.Sprintf("config.%s", strings.ToLower(envName)) + // read from env (only if option wasn't set and flag wasn't changed) + appNameFlag := fs.Lookup(AppName) + if !optionAppNameSet && (appNameFlag == nil || !appNameFlag.Changed) { + if v := viper.GetString(AppName); v != "" { + cfg.AppName = v } + } - if err := cl.loadConfigFile(configName); err != nil { - return nil, err + envNameFlag := fs.Lookup(EnvName) + if !optionEnvNameSet && (envNameFlag == nil || !envNameFlag.Changed) { + if v := viper.GetString(EnvName); v != "" { + cfg.EnvName = v } } - config.AppName = viper.GetString(AppName) - config.EnvName = viper.GetString(EnvName) + // flags override everything + if appNameFlag != nil && appNameFlag.Changed { + cfg.AppName = appNameFlag.Value.String() + } + if envNameFlag != nil && envNameFlag.Changed { + cfg.EnvName = envNameFlag.Value.String() + } + + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + + // always set env prefix based on final app name + prefix := strings.ToUpper(strings.ReplaceAll(cfg.AppName, "-", "_")) + viper.SetEnvPrefix(prefix) - if err := config.Validate(); err != nil { - return nil, fmt.Errorf("configuration validation failed: %w", err) + if len(o.configPaths) > 0 && o.configType != "" { + name := fmt.Sprintf("config.%s", strings.ToLower(cfg.EnvName)) + viper.SetConfigName(name) + viper.SetConfigType(o.configType) + + for _, path := range o.configPaths { + viper.AddConfigPath(path) + } + + if err := viper.ReadInConfig(); err != nil { + var nf viper.ConfigFileNotFoundError + if errors.As(err, &nf) { + return nil, fmt.Errorf("config file '%s.%s' not found in paths %v: %w", + name, o.configType, o.configPaths, err) + } + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // AFTER loading file, read app-name from viper (file values) + // but only if NOT set by flag, env, or option (precedence: flag > env > file > option) + if (appNameFlag == nil || !appNameFlag.Changed) && !optionAppNameSet { + if v := viper.GetString(AppName); v != "" { + cfg.AppName = v + } + } } - return config, nil + return &cfg, nil } diff --git a/config/config_test.go b/config/config_test.go index a45825a..3978a37 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -9,253 +9,21 @@ import ( "github.com/spf13/viper" ) -func TestNewConfigLoader(t *testing.T) { - tests := []struct { - name string - options []Option - expected *ConfigLoader - }{ - { - name: "default config loader", - options: nil, - expected: &ConfigLoader{ - envVarPrefix: "", - configPaths: []string{"./configs", "/configs"}, - configType: "toml", - }, - }, - { - name: "with env prefix", - options: []Option{ - WithEnvPrefix("MYAPP"), - }, - expected: &ConfigLoader{ - envVarPrefix: "MYAPP", - configPaths: []string{"./configs", "/configs"}, - configType: "toml", - }, - }, - { - name: "with custom config paths", - options: []Option{ - WithConfigPaths("./custom", "/etc/app"), - }, - expected: &ConfigLoader{ - envVarPrefix: "", - configPaths: []string{"./custom", "/etc/app"}, - configType: "toml", - }, - }, - { - name: "with config type", - options: []Option{ - WithConfigType("yaml"), - }, - expected: &ConfigLoader{ - envVarPrefix: "", - configPaths: []string{"./configs", "/configs"}, - configType: "yaml", - }, - }, - { - name: "with all options", - options: []Option{ - WithEnvPrefix("TEST"), - WithConfigPaths("./test"), - WithConfigType("json"), - }, - expected: &ConfigLoader{ - envVarPrefix: "TEST", - configPaths: []string{"./test"}, - configType: "json", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - loader := NewConfigLoader(tt.options...) - - if loader.envVarPrefix != tt.expected.envVarPrefix { - t.Errorf("envVarPrefix = %v, want %v", loader.envVarPrefix, tt.expected.envVarPrefix) - } - if len(loader.configPaths) != len(tt.expected.configPaths) { - t.Errorf("configPaths length = %v, want %v", len(loader.configPaths), len(tt.expected.configPaths)) - } - for i, path := range loader.configPaths { - if path != tt.expected.configPaths[i] { - t.Errorf("configPaths[%d] = %v, want %v", i, path, tt.expected.configPaths[i]) - } - } - if loader.configType != tt.expected.configType { - t.Errorf("configType = %v, want %v", loader.configType, tt.expected.configType) - } - }) - } -} - -func TestConfigLoader_LoadConfig(t *testing.T) { - // Reset viper for each test - defer func() { - viper.Reset() - }() - - // Save original args and restore after test - originalArgs := os.Args - defer func() { - os.Args = originalArgs - }() - - tests := []struct { - name string - args []string - loadOpts []LoadOption - flagSetup func(fs *pflag.FlagSet) - envVars map[string]string - wantErr bool - errContain string - validate func(*Config) error - }{ - { - name: "load without config file - default values", - args: []string{"testapp"}, - loadOpts: nil, - wantErr: false, - validate: func(c *Config) error { - if c.AppName != DefaultAppName { - t.Errorf("AppName = %v, want %v", c.AppName, DefaultAppName) - } - if c.EnvName != DefaultEnvName { - t.Errorf("EnvName = %v, want %v", c.EnvName, DefaultEnvName) - } - return nil - }, - }, - { - name: "load with command line flags", - args: []string{"testapp", "--app-name", "myapp", "--env-name", "prod"}, - loadOpts: nil, - wantErr: false, - validate: func(c *Config) error { - if c.AppName != "myapp" { - t.Errorf("AppName = %v, want myapp", c.AppName) - } - if c.EnvName != "prod" { - t.Errorf("EnvName = %v, want prod", c.EnvName) - } - return nil - }, - }, - { - name: "load with environment variables", - args: []string{"testapp"}, - loadOpts: nil, - envVars: map[string]string{ - "TEST_APP_NAME": "envapp", - "TEST_ENV_NAME": "staging", - }, - wantErr: false, - validate: func(c *Config) error { - if c.AppName != "envapp" { - t.Errorf("AppName = %v, want envapp", c.AppName) - } - if c.EnvName != "staging" { - t.Errorf("EnvName = %v, want staging", c.EnvName) - } - return nil - }, - }, - { - name: "load with custom flag", - args: []string{"testapp", "--custom-flag", "value"}, - loadOpts: nil, - flagSetup: func(fs *pflag.FlagSet) { - fs.String("custom-flag", "", "custom flag") - }, - wantErr: false, - }, - { - name: "load with config file enabled", - args: []string{"testapp"}, - loadOpts: []LoadOption{WithConfigFile(true)}, - wantErr: true, - errContain: "Not Found", // Updated to match actual Viper error - }, - { - name: "load with custom config file", - args: []string{"testapp"}, - loadOpts: []LoadOption{WithCustomConfigFile("custom-config")}, - wantErr: true, - errContain: "Not Found", // Updated to match actual Viper error - }, - { - name: "validation error - empty app name", - args: []string{"testapp", "--app-name", ""}, - loadOpts: nil, - wantErr: true, - errContain: "application name cannot be empty", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Reset viper for each test - viper.Reset() - - for key, value := range tt.envVars { - err := os.Setenv(key, value) - if err != nil { - t.Fatalf("failed to set env var %s: %v", key, err) - } - defer func(k string) { - err := os.Unsetenv(k) - if err != nil { - t.Errorf("failed to unset env var %s: %v", k, err) - } - }(key) - } - - // Set up command line args - os.Args = tt.args - - loader := NewConfigLoader(WithEnvPrefix("TEST")) - - var flagSets []func(fs *pflag.FlagSet) - if tt.flagSetup != nil { - flagSets = append(flagSets, tt.flagSetup) - } - - config, err := loader.LoadConfig(tt.loadOpts, flagSets...) - - if (err != nil) != tt.wantErr { - t.Errorf("LoadConfig() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err != nil && tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) { - t.Errorf("LoadConfig() error = %v, want to contain %v", err, tt.errContain) - return - } - - if err == nil && tt.validate != nil { - if err := tt.validate(config); err != nil { - t.Errorf("Validation failed: %v", err) - } - } - }) - } +func resetTest() { + viper.Reset() + pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) } -func TestConfig_Validate(t *testing.T) { +func Test_config_validate(t *testing.T) { tests := []struct { name string - config *Config + cfg *Config wantErr bool errMsg string }{ { name: "valid config", - config: &Config{ + cfg: &Config{ AppName: "myapp", EnvName: "prod", }, @@ -263,7 +31,7 @@ func TestConfig_Validate(t *testing.T) { }, { name: "empty app name", - config: &Config{ + cfg: &Config{ AppName: "", EnvName: "prod", }, @@ -272,7 +40,7 @@ func TestConfig_Validate(t *testing.T) { }, { name: "whitespace app name", - config: &Config{ + cfg: &Config{ AppName: " ", EnvName: "prod", }, @@ -281,7 +49,7 @@ func TestConfig_Validate(t *testing.T) { }, { name: "empty env name", - config: &Config{ + cfg: &Config{ AppName: "myapp", EnvName: "", }, @@ -290,7 +58,7 @@ func TestConfig_Validate(t *testing.T) { }, { name: "whitespace env name", - config: &Config{ + cfg: &Config{ AppName: "myapp", EnvName: " ", }, @@ -301,146 +69,540 @@ func TestConfig_Validate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := tt.config.Validate() + err := tt.cfg.validate() if (err != nil) != tt.wantErr { - t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("Validate() error = %v, want to contain %v", err, tt.errMsg) + t.Errorf("validate() error = %v, want to contain %v", err, tt.errMsg) } }) } } -func TestNormalizeFlags(t *testing.T) { +func Test_defaultConfig(t *testing.T) { + if defaultConfig == nil { + t.Fatal("defaultConfig is nil") + } + + if defaultConfig.AppName != "app" { + t.Errorf("defaultConfig.AppName = %v, want 'app'", defaultConfig.AppName) + } + if defaultConfig.EnvName != "local" { + t.Errorf("defaultConfig.EnvName = %v, want 'local'", defaultConfig.EnvName) + } + + if err := defaultConfig.validate(); err != nil { + t.Errorf("defaultConfig.validate() error = %v", err) + } +} + +func TestNew_Defaults(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + cfg, err := New(fs) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + if cfg.AppName != "app" { + t.Errorf("AppName = %v, want 'app'", cfg.AppName) + } + if cfg.EnvName != "local" { + t.Errorf("EnvName = %v, want 'local'", cfg.EnvName) + } +} + +func TestNew_WithOptions(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + cfg, err := New(fs, + WithAppName("myapp"), + WithEnvName("production"), + ) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + if cfg.AppName != "myapp" { + t.Errorf("AppName = %v, want 'myapp'", cfg.AppName) + } + if cfg.EnvName != "production" { + t.Errorf("EnvName = %v, want 'production'", cfg.EnvName) + } +} + +func TestNew_WithFlags(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp", "--app-name", "flagapp", "--env-name", "staging"} + defer func() { os.Args = originalArgs }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + cfg, err := New(fs, + WithAppName("optionapp"), + WithEnvName("optionenv"), + ) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + if cfg.AppName != "flagapp" { + t.Errorf("AppName = %v, want 'flagapp' (flags should override options)", cfg.AppName) + } + if cfg.EnvName != "staging" { + t.Errorf("EnvName = %v, want 'staging' (flags should override options)", cfg.EnvName) + } +} + +func TestNew_WithEnvVars(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + if err := os.Setenv("MYAPP_PORT", "9000"); err != nil { + t.Fatalf("failed to set env var: %v", err) + } + defer func() { + if err := os.Unsetenv("MYAPP_PORT"); err != nil { + t.Errorf("failed to unset env var: %v", err) + } + }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.Int("port", 8080, "port") + + cfg, err := New(fs, WithAppName("myapp")) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + port := viper.GetInt("port") + if port != 9000 { + t.Errorf("port = %v, want 9000 (from env var MYAPP_PORT)", port) + } + + if cfg.AppName != "myapp" { + t.Errorf("AppName = %v, want 'myapp'", cfg.AppName) + } +} + +func TestNew_EnvVarPrefix(t *testing.T) { tests := []struct { - name string - input string - expected string + name string + appName string + envVar string + envValue string + expectPort int }{ { - name: "flag with underscore", - input: "test_flag", - expected: "test-flag", - }, - { - name: "flag with multiple underscores", - input: "test_flag_name", - expected: "test-flag-name", + name: "simple app name", + appName: "myapp", + envVar: "MYAPP_PORT", + envValue: "7777", + expectPort: 7777, }, { - name: "flag without underscore", - input: "testflag", - expected: "testflag", + name: "app name with hyphen", + appName: "my-app", + envVar: "MY_APP_PORT", + envValue: "8888", + expectPort: 8888, }, { - name: "flag with dash", - input: "test-flag", - expected: "test-flag", + name: "app name with multiple hyphens", + appName: "my-cool-app", + envVar: "MY_COOL_APP_PORT", + envValue: "9999", + expectPort: 9999, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + if err := os.Setenv(tt.envVar, tt.envValue); err != nil { + t.Fatalf("failed to set env var: %v", err) + } + defer func() { + if err := os.Unsetenv(tt.envVar); err != nil { + t.Errorf("failed to unset env var: %v", err) + } + }() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) - normalized := normalizeFlags(fs, tt.input) - if string(normalized) != tt.expected { - t.Errorf("normalizeFlags() = %v, want %v", normalized, tt.expected) + fs.Int("port", 8080, "port") + + cfg, err := New(fs, WithAppName(tt.appName)) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + if cfg.AppName != tt.appName { + t.Errorf("AppName = %v, want %v", cfg.AppName, tt.appName) + } + + port := viper.GetInt("port") + if port != tt.expectPort { + t.Errorf("port = %v, want %v (from env var %s)", port, tt.expectPort, tt.envVar) } }) } } -func TestConfigLoader_AddConfigPath(t *testing.T) { - loader := NewConfigLoader() - initialLen := len(loader.configPaths) +func TestNew_InvalidConfig(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() - loader.AddConfigPath("/new/path") + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) - if len(loader.configPaths) != initialLen+1 { - t.Errorf("AddConfigPath() did not add path, got %d paths, want %d", - len(loader.configPaths), initialLen+1) + _, err := New(fs, WithAppName("")) + if err == nil { + t.Fatal("New() expected error for empty app name, got nil") } - if loader.configPaths[len(loader.configPaths)-1] != "/new/path" { - t.Errorf("AddConfigPath() added wrong path, got %v, want /new/path", - loader.configPaths[len(loader.configPaths)-1]) + if !strings.Contains(err.Error(), "application name cannot be empty") { + t.Errorf("New() error = %v, want error containing 'application name cannot be empty'", err) } +} - // Test chaining - loader.AddConfigPath("/another/path").AddConfigPath("/third/path") +func TestNew_Precedence(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp", "--app-name", "flag-app"} + defer func() { os.Args = originalArgs }() - if len(loader.configPaths) != initialLen+3 { - t.Errorf("Chained AddConfigPath() failed, got %d paths, want %d", - len(loader.configPaths), initialLen+3) + if err := os.Setenv("APP_NAME", "env-app"); err != nil { + t.Fatalf("failed to set env var: %v", err) + } + defer func() { + if err := os.Unsetenv("APP_NAME"); err != nil { + t.Errorf("failed to unset env var: %v", err) + } + }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + cfg, err := New(fs, WithAppName("option-app")) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + // Precedence: flags > env > options > defaults + // flag should win + if cfg.AppName != "flag-app" { + t.Errorf("AppName = %v, want 'flag-app' (flags should have highest precedence)", cfg.AppName) } } -func TestLoadOptions(t *testing.T) { +func TestOptions(t *testing.T) { + strPtr := func(s string) *string { return &s } + tests := []struct { name string - options []LoadOption - expected loadOptions + opts []Option + expected options }{ { - name: "no options", - options: nil, - expected: loadOptions{ - loadConfigFile: false, - configFileName: "", + name: "WithAppName", + opts: []Option{WithAppName("testapp")}, + expected: options{ + appName: strPtr("testapp"), }, }, { - name: "with config file enabled", - options: []LoadOption{WithConfigFile(true)}, - expected: loadOptions{ - loadConfigFile: true, - configFileName: "", + name: "WithEnvName", + opts: []Option{WithEnvName("staging")}, + expected: options{ + envName: strPtr("staging"), }, }, { - name: "with config file disabled", - options: []LoadOption{WithConfigFile(false)}, - expected: loadOptions{ - loadConfigFile: false, - configFileName: "", + name: "WithConfigPaths", + opts: []Option{WithConfigPaths("./configs", "/etc/app")}, + expected: options{ + configPaths: []string{"./configs", "/etc/app"}, }, }, { - name: "with custom config file", - options: []LoadOption{WithCustomConfigFile("custom")}, - expected: loadOptions{ - loadConfigFile: true, - configFileName: "custom", + name: "WithConfigType", + opts: []Option{WithConfigType("yaml")}, + expected: options{ + configType: "yaml", }, }, { - name: "multiple options", - options: []LoadOption{ - WithConfigFile(false), - WithCustomConfigFile("override"), + name: "all options", + opts: []Option{ + WithAppName("myapp"), + WithEnvName("prod"), + WithConfigPaths("./custom"), + WithConfigType("json"), }, - expected: loadOptions{ - loadConfigFile: true, // WithCustomConfigFile overrides - configFileName: "override", + expected: options{ + appName: strPtr("myapp"), + envName: strPtr("prod"), + configPaths: []string{"./custom"}, + configType: "json", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - opts := &loadOptions{} - for _, option := range tt.options { - option(opts) + o := &options{} + for _, opt := range tt.opts { + opt(o) + } + + if (o.appName == nil && tt.expected.appName != nil) || + (o.appName != nil && tt.expected.appName == nil) || + (o.appName != nil && tt.expected.appName != nil && *o.appName != *tt.expected.appName) { + t.Errorf("appName = %v, want %v", + func() string { + if o.appName == nil { + return "" + } + return *o.appName + }(), + func() string { + if tt.expected.appName == nil { + return "" + } + return *tt.expected.appName + }()) } - if opts.loadConfigFile != tt.expected.loadConfigFile { - t.Errorf("loadConfigFile = %v, want %v", opts.loadConfigFile, tt.expected.loadConfigFile) + if (o.envName == nil && tt.expected.envName != nil) || + (o.envName != nil && tt.expected.envName == nil) || + (o.envName != nil && tt.expected.envName != nil && *o.envName != *tt.expected.envName) { + t.Errorf("envName = %v, want %v", + func() string { + if o.envName == nil { + return "" + } + return *o.envName + }(), + func() string { + if tt.expected.envName == nil { + return "" + } + return *tt.expected.envName + }()) } - if opts.configFileName != tt.expected.configFileName { - t.Errorf("configFileName = %v, want %v", opts.configFileName, tt.expected.configFileName) + + if len(o.configPaths) != len(tt.expected.configPaths) { + t.Errorf("configPaths length = %v, want %v", len(o.configPaths), len(tt.expected.configPaths)) + } + if o.configType != tt.expected.configType { + t.Errorf("configType = %v, want %v", o.configType, tt.expected.configType) } }) } } + +func TestNew_FlagRegistration(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + _, err := New(fs, WithAppName("customapp"), WithEnvName("customenv")) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + appNameFlag := fs.Lookup(AppName) + if appNameFlag == nil { + t.Fatal("app-name flag not registered") + } + if appNameFlag.DefValue != "customapp" { + t.Errorf("app-name default = %v, want 'customapp'", appNameFlag.DefValue) + } + + envNameFlag := fs.Lookup(EnvName) + if envNameFlag == nil { + t.Fatal("env-name flag not registered") + } + if envNameFlag.DefValue != "customenv" { + t.Errorf("env-name default = %v, want 'customenv'", envNameFlag.DefValue) + } +} + +func TestNew_WithConfigFile(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.Int("port", 8080, "port") + fs.Bool("debug", false, "debug") + + cfg, err := New(fs, + WithEnvName("local"), + WithConfigPaths("./examples/configs"), + WithConfigType("toml"), + ) + if err != nil { + t.Fatalf("New() with config file unexpected error: %v", err) + } + + if cfg.AppName != "local-app" { + t.Errorf("AppName = %v, want 'local-app' (from config file)", cfg.AppName) + } + + port := viper.GetInt("port") + if port != 8080 { + t.Errorf("port = %v, want 8080 (from config file)", port) + } + + debug := viper.GetBool("debug") + if !debug { + t.Errorf("debug = %v, want true (from config file)", debug) + } +} + +func TestNew_ConfigFileNotFound(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + _, err := New(fs, + WithAppName("test"), + WithEnvName("nonexistent"), + WithConfigPaths("./examples/configs"), + WithConfigType("toml"), + ) + if err == nil { + t.Fatal("New() expected error for missing config file, got nil") + } + + if !strings.Contains(err.Error(), "not found") { + t.Errorf("New() error = %v, want error containing 'not found'", err) + } +} + +func TestNew_CompletePrecedence(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp", "--port", "9999"} + defer func() { os.Args = originalArgs }() + + // Set env var (should lose to flag) + if err := os.Setenv("MYAPP_PORT", "7777"); err != nil { + t.Fatalf("failed to set env var: %v", err) + } + defer func() { + if err := os.Unsetenv("MYAPP_PORT"); err != nil { + t.Errorf("failed to unset env var: %v", err) + } + }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.Int("port", 5555, "port") + + cfg, err := New(fs, + WithAppName("myapp"), + WithEnvName("local"), + WithConfigPaths("./examples/configs"), + WithConfigType("toml"), + ) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + // flag should win over env, file, and default + port := viper.GetInt("port") + if port != 9999 { + t.Errorf("port = %v, want 9999 (from flag, highest precedence)", port) + } + + if cfg.AppName != "myapp" { + t.Errorf("AppName = %v, want 'myapp'", cfg.AppName) + } +} + +func TestNew_NoConfigPathsOrType(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + // no config paths or type = don't load file + cfg, err := New(fs, + WithAppName("test-app"), + WithEnvName("test-env"), + ) + if err != nil { + t.Fatalf("New() without config file unexpected error: %v", err) + } + + if cfg.AppName != "test-app" { + t.Errorf("AppName = %v, want 'test-app'", cfg.AppName) + } + if cfg.EnvName != "test-env" { + t.Errorf("EnvName = %v, want 'test-env'", cfg.EnvName) + } +} + +func TestNew_OnlyConfigPathsNoType(t *testing.T) { + defer resetTest() + + originalArgs := os.Args + os.Args = []string{"testapp"} + defer func() { os.Args = originalArgs }() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + // paths but no type = don't load file + cfg, err := New(fs, + WithAppName("test"), + WithConfigPaths("./examples/configs"), + ) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + if cfg.AppName != "test" { + t.Errorf("AppName = %v, want 'test'", cfg.AppName) + } +} diff --git a/config/examples/main.go b/config/examples/main.go index 5e2cbe6..0449339 100644 --- a/config/examples/main.go +++ b/config/examples/main.go @@ -3,48 +3,50 @@ package main import ( "fmt" "log" - "os" "github.com/alexferl/golib/config" "github.com/spf13/pflag" + "github.com/spf13/viper" ) func main() { - fmt.Println("Try these commands:") + fmt.Println("Example app - try these:") fmt.Println(" go run main.go") - fmt.Println(" go run main.go --app-name myservice --env-name prod --port 3000 --debug") - fmt.Println(" MYAPP_APP_NAME=webapi MYAPP_ENV_NAME=staging go run main.go") - fmt.Println(" go run main.go --env-name prod # loads config.prod.toml") - fmt.Println(" go run main.go --env-name dev # loads config.dev.toml") + fmt.Println(" go run main.go --env-name dev") + fmt.Println(" go run main.go --env-name staging") + fmt.Println(" go run main.go --env-name prod") + fmt.Println(" go run main.go --app-name myservice --env-name prod --port 3000") + fmt.Println(" EXAMPLE_APP_PORT=9999 go run main.go") + fmt.Println(" EXAMPLE_APP_PORT=9999 EXAMPLE_APP_DEBUG=false go run main.go --env-name prod") fmt.Println(" go run main.go --help") fmt.Println() - loader := config.NewConfigLoader( - config.WithEnvPrefix("MYAPP"), - config.WithConfigPaths("./configs"), - config.WithConfigType("toml"), - ) - + // app-specific flags var port int var debug bool - var version bool + pflag.IntVar(&port, "port", 8080, "HTTP server port") + pflag.BoolVar(&debug, "debug", false, "enable debug mode") - cfg, err := loader.LoadConfig( - []config.LoadOption{config.WithConfigFile(true)}, - func(fs *pflag.FlagSet) { - fs.IntVar(&port, "port", 8080, "Server port") - fs.BoolVar(&debug, "debug", false, "Enable debug mode") - fs.BoolVar(&version, "version", false, "Show version") - }, + // create config (registers app-name/env-name flags, parses, loads file) + cfg, err := config.New(pflag.CommandLine, + config.WithAppName("example-app"), + config.WithEnvName("local"), + config.WithConfigPaths("./configs"), + config.WithConfigType("toml"), ) if err != nil { log.Fatalf("Failed to load config: %v", err) } - if version { - fmt.Println("Version: 1.0.0") - os.Exit(0) + // read app-specific values from viper (loaded from config file or flags) + // viper precedence: flags > env > config file > defaults + if viper.IsSet("port") { + port = viper.GetInt("port") + } + if viper.IsSet("debug") { + debug = viper.GetBool("debug") } + // note: cfg.AppName already set from file if present fmt.Printf("\nConfiguration loaded:\n") fmt.Printf(" App Name: %s\n", cfg.AppName) @@ -53,19 +55,12 @@ func main() { fmt.Printf(" Debug: %t\n", debug) fmt.Println() - fmt.Println("Configuration sources (in precedence order):") - fmt.Println(" 1. Command line flags (highest)") - fmt.Println(" 2. Environment variables (MYAPP_*)") - fmt.Printf(" 3. Config file (config.%s.toml)\n", cfg.EnvName) - fmt.Println(" 4. Default values (lowest)") - fmt.Println() - - fmt.Printf("Starting %s server in %s environment on port %d\n", + fmt.Printf("Starting %s in %s environment on port %d\n", cfg.AppName, cfg.EnvName, port) if debug { - fmt.Println("🐛 Debug mode is enabled") + fmt.Println("🐛 Debug mode enabled") } - fmt.Println("✅ Application started successfully!") + fmt.Println("✅ Application started!") } diff --git a/config/go.mod b/config/go.mod index d5de2b5..dd9fbfc 100644 --- a/config/go.mod +++ b/config/go.mod @@ -3,20 +3,20 @@ module github.com/alexferl/golib/config go 1.25 require ( - github.com/spf13/pflag v1.0.7 - github.com/spf13/viper v1.20.1 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 ) require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/sagikazarmark/locafero v0.10.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect - github.com/spf13/afero v1.14.0 // indirect - github.com/spf13/cast v1.9.2 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/config/go.sum b/config/go.sum index 306b7eb..420460c 100644 --- a/config/go.sum +++ b/config/go.sum @@ -18,26 +18,26 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= -github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= -github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/http/middleware/README.md b/http/middleware/README.md deleted file mode 100644 index 277b329..0000000 --- a/http/middleware/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# middleware -Configurable HTTP middleware components for Echo framework with CLI flag support. - -## Installing - -```shell -go get github.com/alexferl/golib/http/middleware -``` - -## Usage -See [examples/](examples/) for usage. diff --git a/http/middleware/body_limit.go b/http/middleware/body_limit.go deleted file mode 100644 index 469e4b8..0000000 --- a/http/middleware/body_limit.go +++ /dev/null @@ -1,46 +0,0 @@ -package middleware - -import ( - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" -) - -// BodyLimit holds configuration for limiting request body size. -type BodyLimit struct { - // Enabled indicates whether body limit middleware is enabled. - // Optional. Default value false. - Enabled bool - - // MaxSize specifies the maximum request body size (e.g., "1MB", "2KB"). - // Optional. Default value "1MB". - MaxSize string -} - -// DefaultBodyLimit provides default BodyLimit configuration. -var DefaultBodyLimit = &BodyLimit{ - Enabled: false, - MaxSize: "1MB", -} - -const ( - BodyLimitEnabled = "body-limit-enabled" - BodyLimitMaxSize = "body-limit-max-size" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (b *BodyLimit) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Body Limit", pflag.ExitOnError) - - fs.BoolVar(&b.Enabled, BodyLimitEnabled, b.Enabled, "Enable request body size limiting") - fs.StringVar(&b.MaxSize, BodyLimitMaxSize, b.MaxSize, "Maximum request body size (e.g., 1MB, 2KB)") - - return fs -} - -// NewBodyLimit creates a new body limit middleware with the given configuration. -func NewBodyLimit(config *BodyLimit) echo.MiddlewareFunc { - return middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{ - Limit: config.MaxSize, - }) -} diff --git a/http/middleware/body_limit_test.go b/http/middleware/body_limit_test.go deleted file mode 100644 index a5d4c1e..0000000 --- a/http/middleware/body_limit_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package middleware - -import ( - "testing" -) - -func TestBodyLimit_FlagSet(t *testing.T) { - config := &BodyLimit{ - Enabled: true, - MaxSize: "2MB", - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(BodyLimitEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", BodyLimitEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", BodyLimitEnabled, enabledFlag.DefValue) - } - } - - maxSizeFlag := fs.Lookup(BodyLimitMaxSize) - if maxSizeFlag == nil { - t.Errorf("Flag %s not found", BodyLimitMaxSize) - } else { - if maxSizeFlag.DefValue != "2MB" { - t.Errorf("Flag %s default value = %v, want 2MB", BodyLimitMaxSize, maxSizeFlag.DefValue) - } - } -} - -func TestBodyLimit_FlagSet_Parse(t *testing.T) { - config := &BodyLimit{ - Enabled: false, - MaxSize: "1MB", - } - - fs := config.FlagSet() - - args := []string{ - "--body-limit-enabled", - "--body-limit-max-size", "5MB", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } - if config.MaxSize != "5MB" { - t.Errorf("MaxSize = %v, want 5MB", config.MaxSize) - } -} - -func TestDefaultBodyLimit(t *testing.T) { - if DefaultBodyLimit == nil { - t.Fatal("DefaultBodyLimit is nil") - } - - if DefaultBodyLimit.Enabled != false { - t.Errorf("DefaultBodyLimit.Enabled = %v, want false", DefaultBodyLimit.Enabled) - } - if DefaultBodyLimit.MaxSize != "1MB" { - t.Errorf("DefaultBodyLimit.MaxSize = %v, want 1MB", DefaultBodyLimit.MaxSize) - } -} - -func TestBodyLimit_FlagSet_DefaultValues(t *testing.T) { - config := &BodyLimit{ - Enabled: true, - MaxSize: "10MB", - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(BodyLimitEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } - - maxSizeFlag := fs.Lookup(BodyLimitMaxSize) - if maxSizeFlag == nil { - t.Fatal("MaxSize flag not found") - } - if maxSizeFlag.DefValue != "10MB" { - t.Errorf("MaxSize flag default = %v, want 10MB", maxSizeFlag.DefValue) - } -} - -func TestBodyLimit_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultBodyLimit - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestNewBodyLimit(t *testing.T) { - config := &BodyLimit{ - Enabled: true, - MaxSize: "1KB", - } - - middleware := NewBodyLimit(config) - if middleware == nil { - t.Fatal("NewBodyLimit() returned nil") - } -} - -func TestNewBodyLimit_DefaultConfig(t *testing.T) { - middleware := NewBodyLimit(DefaultBodyLimit) - if middleware == nil { - t.Fatal("NewBodyLimit() with DefaultBodyLimit returned nil") - } -} diff --git a/http/middleware/cors.go b/http/middleware/cors.go deleted file mode 100644 index 05c3494..0000000 --- a/http/middleware/cors.go +++ /dev/null @@ -1,84 +0,0 @@ -package middleware - -import ( - "net/http" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" -) - -// CORS holds configuration for Cross-Origin Resource Sharing. -type CORS struct { - // Enabled indicates whether CORS middleware is enabled. - // Optional. Default value false. - Enabled bool - - // AllowOrigins specifies the allowed origins for CORS requests. - // Optional. Default value []string{"*"}. - AllowOrigins []string - - // AllowMethods specifies the allowed HTTP methods for CORS requests. - // Optional. Default value []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete}. - AllowMethods []string - - // AllowHeaders specifies the allowed headers for CORS requests. - // Optional. Default value []string{}. - AllowHeaders []string - - // AllowCredentials indicates whether credentials are allowed in CORS requests. - // Optional. Default value false. - AllowCredentials bool - - // ExposeHeaders specifies the headers exposed to the client. - // Optional. Default value []string{}. - ExposeHeaders []string - - // MaxAge specifies the maximum age for preflight requests in seconds. - // Optional. Default value 0. - MaxAge int -} - -// DefaultCORS provides default CORS configuration. -var DefaultCORS = &CORS{ - Enabled: false, - AllowOrigins: []string{"*"}, - AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete}, -} - -const ( - CORSEnabled = "cors-enabled" - CORSAllowOrigins = "cors-allow-origins" - CORSAllowMethods = "cors-allow-methods" - CORSAllowHeaders = "cors-allow-headers" - CORSAllowCredentials = "cors-allow-credentials" - CORSExposeHeaders = "cors-expose-headers" - CORSMaxAge = "cors-max-age" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (c *CORS) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("CORS", pflag.ExitOnError) - - fs.BoolVar(&c.Enabled, CORSEnabled, c.Enabled, "Enable CORS middleware") - fs.StringSliceVar(&c.AllowOrigins, CORSAllowOrigins, c.AllowOrigins, "Allowed origins for CORS requests") - fs.StringSliceVar(&c.AllowMethods, CORSAllowMethods, c.AllowMethods, "Allowed HTTP methods for CORS requests") - fs.StringSliceVar(&c.AllowHeaders, CORSAllowHeaders, c.AllowHeaders, "Allowed headers for CORS requests") - fs.BoolVar(&c.AllowCredentials, CORSAllowCredentials, c.AllowCredentials, "Allow credentials in CORS requests") - fs.StringSliceVar(&c.ExposeHeaders, CORSExposeHeaders, c.ExposeHeaders, "Headers exposed to the client") - fs.IntVar(&c.MaxAge, CORSMaxAge, c.MaxAge, "Maximum age for preflight requests in seconds") - - return fs -} - -// NewCORS creates a new CORS middleware with the given configuration. -func NewCORS(config *CORS) echo.MiddlewareFunc { - return middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: config.AllowOrigins, - AllowMethods: config.AllowMethods, - AllowHeaders: config.AllowHeaders, - AllowCredentials: config.AllowCredentials, - ExposeHeaders: config.ExposeHeaders, - MaxAge: config.MaxAge, - }) -} diff --git a/http/middleware/cors_test.go b/http/middleware/cors_test.go deleted file mode 100644 index 8ddbd46..0000000 --- a/http/middleware/cors_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package middleware - -import ( - "net/http" - "reflect" - "testing" -) - -func TestCORS_FlagSet(t *testing.T) { - config := &CORS{ - Enabled: true, - AllowOrigins: []string{"https://example.com"}, - AllowMethods: []string{http.MethodGet, http.MethodPost}, - AllowHeaders: []string{"Content-Type"}, - AllowCredentials: true, - ExposeHeaders: []string{"X-Custom-Header"}, - MaxAge: 3600, - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(CORSEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", CORSEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", CORSEnabled, enabledFlag.DefValue) - } - } - - allowOriginsFlag := fs.Lookup(CORSAllowOrigins) - if allowOriginsFlag == nil { - t.Errorf("Flag %s not found", CORSAllowOrigins) - } else { - if allowOriginsFlag.DefValue != "[https://example.com]" { - t.Errorf("Flag %s default value = %v, want [https://example.com]", CORSAllowOrigins, allowOriginsFlag.DefValue) - } - } - - maxAgeFlag := fs.Lookup(CORSMaxAge) - if maxAgeFlag == nil { - t.Errorf("Flag %s not found", CORSMaxAge) - } else { - if maxAgeFlag.DefValue != "3600" { - t.Errorf("Flag %s default value = %v, want 3600", CORSMaxAge, maxAgeFlag.DefValue) - } - } -} - -func TestCORS_FlagSet_Parse(t *testing.T) { - config := &CORS{ - Enabled: false, - AllowOrigins: []string{"*"}, - AllowMethods: []string{http.MethodGet}, - AllowHeaders: []string{}, - AllowCredentials: false, - ExposeHeaders: []string{}, - MaxAge: 0, - } - - fs := config.FlagSet() - - args := []string{ - "--cors-enabled", - "--cors-allow-origins", "https://example.com,https://example.org", - "--cors-allow-methods", "GET,POST,DELETE", - "--cors-allow-headers", "Authorization,Content-Type", - "--cors-allow-credentials", - "--cors-expose-headers", "X-Custom-Header", - "--cors-max-age", "7200", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } - expectedOrigins := []string{"https://example.com", "https://example.org"} - if !reflect.DeepEqual(config.AllowOrigins, expectedOrigins) { - t.Errorf("AllowOrigins = %v, want %v", config.AllowOrigins, expectedOrigins) - } - expectedMethods := []string{"GET", "POST", "DELETE"} - if !reflect.DeepEqual(config.AllowMethods, expectedMethods) { - t.Errorf("AllowMethods = %v, want %v", config.AllowMethods, expectedMethods) - } - if !config.AllowCredentials { - t.Errorf("AllowCredentials = %v, want true", config.AllowCredentials) - } - if config.MaxAge != 7200 { - t.Errorf("MaxAge = %v, want 7200", config.MaxAge) - } -} - -func TestDefaultCORS(t *testing.T) { - if DefaultCORS == nil { - t.Fatal("DefaultCORS is nil") - } - - if DefaultCORS.Enabled != false { - t.Errorf("DefaultCORS.Enabled = %v, want false", DefaultCORS.Enabled) - } - expectedOrigins := []string{"*"} - if !reflect.DeepEqual(DefaultCORS.AllowOrigins, expectedOrigins) { - t.Errorf("DefaultCORS.AllowOrigins = %v, want %v", DefaultCORS.AllowOrigins, expectedOrigins) - } - expectedMethods := []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete} - if !reflect.DeepEqual(DefaultCORS.AllowMethods, expectedMethods) { - t.Errorf("DefaultCORS.AllowMethods = %v, want %v", DefaultCORS.AllowMethods, expectedMethods) - } -} - -func TestCORS_FlagSet_DefaultValues(t *testing.T) { - config := &CORS{ - Enabled: true, - AllowOrigins: []string{"https://test.com"}, - AllowMethods: []string{http.MethodGet}, - AllowHeaders: []string{"Authorization"}, - AllowCredentials: true, - ExposeHeaders: []string{"X-Test"}, - MaxAge: 1800, - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(CORSEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } - - maxAgeFlag := fs.Lookup(CORSMaxAge) - if maxAgeFlag == nil { - t.Fatal("MaxAge flag not found") - } - if maxAgeFlag.DefValue != "1800" { - t.Errorf("MaxAge flag default = %v, want 1800", maxAgeFlag.DefValue) - } -} - -func TestCORS_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultCORS - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestNewCORS(t *testing.T) { - config := &CORS{ - Enabled: true, - AllowOrigins: []string{"https://example.com"}, - AllowMethods: []string{"GET"}, - } - - middleware := NewCORS(config) - if middleware == nil { - t.Fatal("NewCORS() returned nil") - } -} - -func TestNewCORS_DefaultConfig(t *testing.T) { - middleware := NewCORS(DefaultCORS) - if middleware == nil { - t.Fatal("NewCORS() with DefaultCORS returned nil") - } -} diff --git a/http/middleware/csrf.go b/http/middleware/csrf.go deleted file mode 100644 index 03fa372..0000000 --- a/http/middleware/csrf.go +++ /dev/null @@ -1,181 +0,0 @@ -package middleware - -import ( - "fmt" - "net/http" - "strings" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" -) - -type CSRFSameSiteMode http.SameSite - -const ( - csrfSameSiteDefaultMode = "default" - csrfSameSiteLaxMode = "lax" - csrfSameSiteStrictMode = "strict" - csrfSameSiteNoneMode = "none" -) - -const ( - CSRFSameSiteDefaultMode CSRFSameSiteMode = iota + 1 - CSRFSameSiteLaxMode - CSRFSameSiteStrictMode - CSRFSameSiteNoneMode -) - -var CSRFSameSiteModes = []string{csrfSameSiteDefaultMode, csrfSameSiteLaxMode, csrfSameSiteStrictMode, csrfSameSiteNoneMode} - -func (m *CSRFSameSiteMode) String() string { - switch *m { - case CSRFSameSiteDefaultMode: - return csrfSameSiteDefaultMode - case CSRFSameSiteLaxMode: - return csrfSameSiteLaxMode - case CSRFSameSiteStrictMode: - return csrfSameSiteStrictMode - case CSRFSameSiteNoneMode: - return csrfSameSiteNoneMode - default: - return fmt.Sprintf("unknown mode: %d", *m) - } -} - -func (m *CSRFSameSiteMode) Set(value string) error { - switch strings.ToLower(value) { - case csrfSameSiteDefaultMode: - *m = CSRFSameSiteMode(http.SameSiteDefaultMode) - return nil - case csrfSameSiteLaxMode: - *m = CSRFSameSiteMode(http.SameSiteLaxMode) - return nil - case csrfSameSiteStrictMode: - *m = CSRFSameSiteMode(http.SameSiteStrictMode) - return nil - case csrfSameSiteNoneMode: - *m = CSRFSameSiteMode(http.SameSiteNoneMode) - return nil - default: - return fmt.Errorf("invalid same site mode: %s (must be one of: %s)", value, strings.Join(CSRFSameSiteModes, ", ")) - } -} - -func (m *CSRFSameSiteMode) Type() string { - return "string" -} - -// CSRF holds configuration for Cross-Site Request Forgery protection. -type CSRF struct { - // Enabled indicates whether CSRF middleware is enabled. - // Optional. Default value false. - Enabled bool - - // TokenLength specifies the length of the CSRF token. - // Optional. Default value 32. - TokenLength uint8 - - // TokenLookup is a string in the form of ":" or ":,:" that is used - // to extract token from the request. - // Optional. Default value "header:X-CSRF-Token". - // Possible values: - // - "header:" or "header::" - // - "query:" - // - "form:" - // Multiple sources example: - // - "header:X-CSRF-Token,query:csrf" - TokenLookup string - - // ContextKey specifies the key used to store CSRF token in context. - // Optional. Default value "csrf". - ContextKey string - - // CookieName specifies the name of the CSRF cookie. - // Optional. Default value "_csrf". - CookieName string - - // CookieDomain specifies the domain for the CSRF cookie. - // Optional. Default value "". - CookieDomain string - - // CookiePath specifies the path for the CSRF cookie. - // Optional. Default value "". - CookiePath string - - // CookieMaxAge specifies the max age for the CSRF cookie in seconds. - // Optional. Default value 86400. - CookieMaxAge int - - // CookieSecure indicates whether the CSRF cookie should be secure. - // Optional. Default value false. - CookieSecure bool - - // CookieHTTPOnly indicates whether the CSRF cookie should be HTTP only. - // Optional. Default value false. - CookieHTTPOnly bool - - // CookieSameSite specifies the SameSite attribute for the CSRF cookie. - // Optional. Default value SameSiteDefaultMode. - CookieSameSite CSRFSameSiteMode -} - -// DefaultCSRF provides default CSRF configuration. -var DefaultCSRF = &CSRF{ - Enabled: false, - TokenLength: 32, - TokenLookup: "header:X-CSRF-Token", - ContextKey: "csrf", - CookieName: "_csrf", - CookieMaxAge: 86400, - CookieSameSite: CSRFSameSiteMode(http.SameSiteDefaultMode), -} - -const ( - CSRFEnabled = "csrf-enabled" - CSRFTokenLength = "csrf-token-length" - CSRFTokenLookup = "csrf-token-lookup" - CSRFContextKey = "csrf-context-key" - CSRFCookieName = "csrf-cookie-name" - CSRFCookieDomain = "csrf-cookie-domain" - CSRFCookiePath = "csrf-cookie-path" - CSRFCookieMaxAge = "csrf-cookie-max-age" - CSRFCookieSecure = "csrf-cookie-secure" - CSRFCookieHTTPOnly = "csrf-cookie-http-only" - CSRFCookieSameSite = "csrf-cookie-same-site" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (c *CSRF) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("CSRF", pflag.ExitOnError) - - fs.BoolVar(&c.Enabled, CSRFEnabled, c.Enabled, "Enable CSRF protection middleware") - fs.Uint8Var(&c.TokenLength, CSRFTokenLength, c.TokenLength, "Length of the CSRF token") - fs.StringVar(&c.TokenLookup, CSRFTokenLookup, c.TokenLookup, "Where to look for the CSRF token") - fs.StringVar(&c.ContextKey, CSRFContextKey, c.ContextKey, "Key used to store CSRF token in context") - fs.StringVar(&c.CookieName, CSRFCookieName, c.CookieName, "Name of the CSRF cookie") - fs.StringVar(&c.CookieDomain, CSRFCookieDomain, c.CookieDomain, "Domain for the CSRF cookie") - fs.StringVar(&c.CookiePath, CSRFCookiePath, c.CookiePath, "Path for the CSRF cookie") - fs.IntVar(&c.CookieMaxAge, CSRFCookieMaxAge, c.CookieMaxAge, "Max age for the CSRF cookie in seconds") - fs.BoolVar(&c.CookieSecure, CSRFCookieSecure, c.CookieSecure, "Whether the CSRF cookie should be secure") - fs.BoolVar(&c.CookieHTTPOnly, CSRFCookieHTTPOnly, c.CookieHTTPOnly, "Whether the CSRF cookie should be HTTP only") - fs.Var(&c.CookieSameSite, CSRFCookieSameSite, fmt.Sprintf("SameSite attribute for CSRF cookie\nValues: %s", strings.Join(CSRFSameSiteModes, ", "))) - - return fs -} - -// NewCSRF creates a new CSRF middleware with the given configuration. -func NewCSRF(config *CSRF) echo.MiddlewareFunc { - return middleware.CSRFWithConfig(middleware.CSRFConfig{ - TokenLength: config.TokenLength, - TokenLookup: config.TokenLookup, - ContextKey: config.ContextKey, - CookieName: config.CookieName, - CookieDomain: config.CookieDomain, - CookiePath: config.CookiePath, - CookieMaxAge: config.CookieMaxAge, - CookieSecure: config.CookieSecure, - CookieHTTPOnly: config.CookieHTTPOnly, - CookieSameSite: http.SameSite(config.CookieSameSite), - }) -} diff --git a/http/middleware/csrf_test.go b/http/middleware/csrf_test.go deleted file mode 100644 index 0211a0f..0000000 --- a/http/middleware/csrf_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package middleware - -import ( - "net/http" - "testing" -) - -func TestCSRF_FlagSet(t *testing.T) { - config := &CSRF{ - Enabled: true, - TokenLength: 16, - TokenLookup: "form:_token", - ContextKey: "token", - CookieName: "_token", - CookieDomain: "example.com", - CookiePath: "/api", - CookieMaxAge: 3600, - CookieSecure: true, - CookieHTTPOnly: true, - CookieSameSite: CSRFSameSiteMode(http.SameSiteStrictMode), - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(CSRFEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", CSRFEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", CSRFEnabled, enabledFlag.DefValue) - } - } - - tokenLengthFlag := fs.Lookup(CSRFTokenLength) - if tokenLengthFlag == nil { - t.Errorf("Flag %s not found", CSRFTokenLength) - } else { - if tokenLengthFlag.DefValue != "16" { - t.Errorf("Flag %s default value = %v, want 16", CSRFTokenLength, tokenLengthFlag.DefValue) - } - } - - cookieNameFlag := fs.Lookup(CSRFCookieName) - if cookieNameFlag == nil { - t.Errorf("Flag %s not found", CSRFCookieName) - } else { - if cookieNameFlag.DefValue != "_token" { - t.Errorf("Flag %s default value = %v, want _token", CSRFCookieName, cookieNameFlag.DefValue) - } - } -} - -func TestCSRF_FlagSet_Parse(t *testing.T) { - config := &CSRF{ - Enabled: false, - TokenLength: 32, - TokenLookup: "header:X-CSRF-Token", - ContextKey: "csrf", - CookieName: "_csrf", - CookieMaxAge: 86400, - CookieSecure: false, - CookieHTTPOnly: false, - CookieSameSite: CSRFSameSiteMode(http.SameSiteDefaultMode), - } - - fs := config.FlagSet() - - args := []string{ - "--csrf-enabled", - "--csrf-token-length", "64", - "--csrf-token-lookup", "form:csrf_token", - "--csrf-context-key", "token", - "--csrf-cookie-name", "_token", - "--csrf-cookie-domain", "example.com", - "--csrf-cookie-path", "/secure", - "--csrf-cookie-max-age", "7200", - "--csrf-cookie-secure", - "--csrf-cookie-http-only", - "--csrf-cookie-same-site", "strict", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } - if config.TokenLength != 64 { - t.Errorf("TokenLength = %v, want 64", config.TokenLength) - } - if config.TokenLookup != "form:csrf_token" { - t.Errorf("TokenLookup = %v, want form:csrf_token", config.TokenLookup) - } - if config.ContextKey != "token" { - t.Errorf("ContextKey = %v, want token", config.ContextKey) - } - if config.CookieName != "_token" { - t.Errorf("CookieName = %v, want _token", config.CookieName) - } - if config.CookieDomain != "example.com" { - t.Errorf("CookieDomain = %v, want example.com", config.CookieDomain) - } - if config.CookiePath != "/secure" { - t.Errorf("CookiePath = %v, want /secure", config.CookiePath) - } - if config.CookieMaxAge != 7200 { - t.Errorf("CookieMaxAge = %v, want 7200", config.CookieMaxAge) - } - if !config.CookieSecure { - t.Errorf("CookieSecure = %v, want true", config.CookieSecure) - } - if !config.CookieHTTPOnly { - t.Errorf("CookieHTTPOnly = %v, want true", config.CookieHTTPOnly) - } - if config.CookieSameSite != CSRFSameSiteMode(http.SameSiteStrictMode) { - t.Errorf("CookieSameSite = %v, want %v", config.CookieSameSite, CSRFSameSiteMode(http.SameSiteStrictMode)) - } -} - -func TestDefaultCSRF(t *testing.T) { - if DefaultCSRF == nil { - t.Fatal("DefaultCSRF is nil") - } - - if DefaultCSRF.Enabled != false { - t.Errorf("DefaultCSRF.Enabled = %v, want false", DefaultCSRF.Enabled) - } - if DefaultCSRF.TokenLength != 32 { - t.Errorf("DefaultCSRF.TokenLength = %v, want 32", DefaultCSRF.TokenLength) - } - if DefaultCSRF.TokenLookup != "header:X-CSRF-Token" { - t.Errorf("DefaultCSRF.TokenLookup = %v, want header:X-CSRF-Token", DefaultCSRF.TokenLookup) - } - if DefaultCSRF.ContextKey != "csrf" { - t.Errorf("DefaultCSRF.ContextKey = %v, want csrf", DefaultCSRF.ContextKey) - } - if DefaultCSRF.CookieName != "_csrf" { - t.Errorf("DefaultCSRF.CookieName = %v, want _csrf", DefaultCSRF.CookieName) - } - if DefaultCSRF.CookieMaxAge != 86400 { - t.Errorf("DefaultCSRF.CookieMaxAge = %v, want 86400", DefaultCSRF.CookieMaxAge) - } - if DefaultCSRF.CookieSameSite != CSRFSameSiteMode(http.SameSiteDefaultMode) { - t.Errorf("DefaultCSRF.CookieSameSite = %v, want %v", DefaultCSRF.CookieSameSite, CSRFSameSiteMode(http.SameSiteDefaultMode)) - } -} - -func TestCSRF_FlagSet_DefaultValues(t *testing.T) { - config := &CSRF{ - Enabled: true, - TokenLength: 16, - CookieName: "_test", - CookieMaxAge: 1800, - CookieSecure: true, - CookieHTTPOnly: false, - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(CSRFEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } - - tokenLengthFlag := fs.Lookup(CSRFTokenLength) - if tokenLengthFlag == nil { - t.Fatal("TokenLength flag not found") - } - if tokenLengthFlag.DefValue != "16" { - t.Errorf("TokenLength flag default = %v, want 16", tokenLengthFlag.DefValue) - } - - cookieNameFlag := fs.Lookup(CSRFCookieName) - if cookieNameFlag == nil { - t.Fatal("CookieName flag not found") - } - if cookieNameFlag.DefValue != "_test" { - t.Errorf("CookieName flag default = %v, want _test", cookieNameFlag.DefValue) - } -} - -func TestCSRF_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultCSRF - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestCSRFSameSiteMode_String(t *testing.T) { - tests := []struct { - mode CSRFSameSiteMode - want string - }{ - {CSRFSameSiteDefaultMode, "default"}, - {CSRFSameSiteLaxMode, "lax"}, - {CSRFSameSiteStrictMode, "strict"}, - {CSRFSameSiteNoneMode, "none"}, - {CSRFSameSiteMode(99), "unknown mode: 99"}, - } - - for _, tt := range tests { - got := tt.mode.String() - if got != tt.want { - t.Errorf("CSRFSameSiteMode(%d).String() = %v, want %v", tt.mode, got, tt.want) - } - } -} - -func TestCSRFSameSiteMode_Set(t *testing.T) { - tests := []struct { - value string - want CSRFSameSiteMode - wantErr bool - }{ - {"default", CSRFSameSiteMode(http.SameSiteDefaultMode), false}, - {"lax", CSRFSameSiteMode(http.SameSiteLaxMode), false}, - {"strict", CSRFSameSiteMode(http.SameSiteStrictMode), false}, - {"none", CSRFSameSiteMode(http.SameSiteNoneMode), false}, - {"DEFAULT", CSRFSameSiteMode(http.SameSiteDefaultMode), false}, - {"LAX", CSRFSameSiteMode(http.SameSiteLaxMode), false}, - {"invalid", CSRFSameSiteMode(0), true}, - } - - for _, tt := range tests { - var mode CSRFSameSiteMode - err := mode.Set(tt.value) - if tt.wantErr { - if err == nil { - t.Errorf("CSRFSameSiteMode.Set(%q) expected error but got nil", tt.value) - } - } else { - if err != nil { - t.Errorf("CSRFSameSiteMode.Set(%q) unexpected error: %v", tt.value, err) - } - if mode != tt.want { - t.Errorf("CSRFSameSiteMode.Set(%q) = %v, want %v", tt.value, mode, tt.want) - } - } - } -} - -func TestCSRFSameSiteMode_Type(t *testing.T) { - var mode CSRFSameSiteMode - if got := mode.Type(); got != "string" { - t.Errorf("CSRFSameSiteMode.Type() = %v, want string", got) - } -} - -func TestNewCSRF(t *testing.T) { - config := &CSRF{ - Enabled: true, - TokenLength: 32, - CookieName: "_csrf", - } - - middleware := NewCSRF(config) - if middleware == nil { - t.Fatal("NewCSRF() returned nil") - } -} - -func TestNewCSRF_DefaultConfig(t *testing.T) { - middleware := NewCSRF(DefaultCSRF) - if middleware == nil { - t.Fatal("NewCSRF() with DefaultCSRF returned nil") - } -} diff --git a/http/middleware/go.mod b/http/middleware/go.mod deleted file mode 100644 index 8f56377..0000000 --- a/http/middleware/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module github.com/alexferl/golib/http/middleware - -go 1.25 - -require ( - github.com/alexferl/echo-secure v0.3.0 - github.com/alexferl/golib/logger v0.1.0 - github.com/gorilla/sessions v1.4.0 - github.com/labstack/echo-contrib v0.17.4 - github.com/labstack/echo/v4 v4.13.4 - github.com/spf13/pflag v1.0.7 - golang.org/x/time v0.12.0 -) - -require ( - github.com/gorilla/context v1.1.2 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect - github.com/labstack/gommon v0.4.2 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect -) diff --git a/http/middleware/go.sum b/http/middleware/go.sum deleted file mode 100644 index 09a502e..0000000 --- a/http/middleware/go.sum +++ /dev/null @@ -1,58 +0,0 @@ -github.com/alexferl/echo-secure v0.3.0 h1:1teXFQOLLs3Kd3gbK9GmSFDFeA71vkp2C5elsSnPIbY= -github.com/alexferl/echo-secure v0.3.0/go.mod h1:kmbtudSP58dit5bxMcNBxIuKQuJXuvvdEx2ZVfqPDPE= -github.com/alexferl/golib/logger v0.1.0 h1:dy9vWTmo3ihxssPPTXGvfdE22QxluiGxHtbEVDMo+WU= -github.com/alexferl/golib/logger v0.1.0/go.mod h1:BYS2kRGdAWSPDvpIjgsnc28b3ckhVa1y6H3qJo7dCSw= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= -github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= -github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= -github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= -github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= -github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= -github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http/middleware/rate_limiter.go b/http/middleware/rate_limiter.go deleted file mode 100644 index 778dc44..0000000 --- a/http/middleware/rate_limiter.go +++ /dev/null @@ -1,124 +0,0 @@ -package middleware - -import ( - "fmt" - "strings" - "time" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" - "golang.org/x/time/rate" -) - -// RateLimiterMemoryStore holds configuration for memory-based rate limiting. -type RateLimiterMemoryStore struct { - // Rate specifies the rate limit per second. - // Optional. Default value 0. - Rate float64 - - // Burst specifies the burst limit. - // Optional. Default value 0. - Burst int - - // ExpiresIn specifies the expiration time for rate limit entries. - // Optional. Default value 3 minutes. - ExpiresIn time.Duration -} - -type RateLimiterStore string - -const ( - limiterStoreMemory = "memory" -) - -const ( - LimiterStoreMemory RateLimiterStore = limiterStoreMemory -) - -var LimiterStores = []string{limiterStoreMemory} - -func (s *RateLimiterStore) String() string { - switch *s { - case LimiterStoreMemory: - return limiterStoreMemory - default: - return fmt.Sprintf("unknown store: %s", *s) - } -} - -func (s *RateLimiterStore) Set(value string) error { - switch strings.ToLower(value) { - case limiterStoreMemory: - *s = LimiterStoreMemory - return nil - default: - return fmt.Errorf("invalid rate limiter store: %s (must be one of: %s)", value, strings.Join(LimiterStores, ", ")) - } -} - -func (s *RateLimiterStore) Type() string { - return "string" -} - -// RateLimiter holds configuration for rate limiting middleware. -type RateLimiter struct { - // Enabled indicates whether rate limiting middleware is enabled. - // Optional. Default value false. - Enabled bool - - // Store specifies the rate limiter store type. - // Optional. Default value "memory". - Store RateLimiterStore - - // Memory holds memory store configuration. - // Optional. Default value with 3 minute expiration. - Memory RateLimiterMemoryStore -} - -// DefaultRateLimiter provides default RateLimiter configuration. -var DefaultRateLimiter = &RateLimiter{ - Enabled: false, - Store: LimiterStoreMemory, - Memory: RateLimiterMemoryStore{ - Rate: 0, - Burst: 0, - ExpiresIn: 3 * time.Minute, - }, -} - -const ( - RateLimiterEnabled = "rate-limiter-enabled" - RateLimiterStoreType = "rate-limiter-store" - RateLimiterMemoryRate = "rate-limiter-memory-rate" - RateLimiterMemoryBurst = "rate-limiter-memory-burst" - RateLimiterMemoryExpires = "rate-limiter-memory-expires" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (r *RateLimiter) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Rate Limiter", pflag.ExitOnError) - - fs.BoolVar(&r.Enabled, RateLimiterEnabled, r.Enabled, "Enable rate limiting middleware") - fs.Var(&r.Store, RateLimiterStoreType, fmt.Sprintf("Rate limiter store type\nValues: %s", strings.Join(LimiterStores, ", "))) - fs.Float64Var(&r.Memory.Rate, RateLimiterMemoryRate, r.Memory.Rate, "Rate limit per second for memory store") - fs.IntVar(&r.Memory.Burst, RateLimiterMemoryBurst, r.Memory.Burst, "Burst limit for memory store") - fs.DurationVar(&r.Memory.ExpiresIn, RateLimiterMemoryExpires, r.Memory.ExpiresIn, "Expiration time for rate limit entries") - - return fs -} - -// NewRateLimiter creates a new rate limiter middleware with the given configuration. -func NewRateLimiter(config *RateLimiter) echo.MiddlewareFunc { - switch config.Store { - case LimiterStoreMemory: - s := middleware.NewRateLimiterMemoryStoreWithConfig(middleware.RateLimiterMemoryStoreConfig{ - Rate: rate.Limit(config.Memory.Rate), - Burst: config.Memory.Burst, - ExpiresIn: config.Memory.ExpiresIn, - }) - - return middleware.RateLimiter(s) - } - return nil -} diff --git a/http/middleware/rate_limiter_test.go b/http/middleware/rate_limiter_test.go deleted file mode 100644 index 18acd18..0000000 --- a/http/middleware/rate_limiter_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package middleware - -import ( - "testing" - "time" -) - -func TestRateLimiter_FlagSet(t *testing.T) { - config := &RateLimiter{ - Enabled: true, - Store: LimiterStoreMemory, - Memory: RateLimiterMemoryStore{ - Rate: 10.5, - Burst: 100, - ExpiresIn: 5 * time.Minute, - }, - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(RateLimiterEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", RateLimiterEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", RateLimiterEnabled, enabledFlag.DefValue) - } - } - - storeFlag := fs.Lookup(RateLimiterStoreType) - if storeFlag == nil { - t.Errorf("Flag %s not found", RateLimiterStoreType) - } else { - if storeFlag.DefValue != "memory" { - t.Errorf("Flag %s default value = %v, want memory", RateLimiterStoreType, storeFlag.DefValue) - } - } - - rateFlag := fs.Lookup(RateLimiterMemoryRate) - if rateFlag == nil { - t.Errorf("Flag %s not found", RateLimiterMemoryRate) - } else { - if rateFlag.DefValue != "10.5" { - t.Errorf("Flag %s default value = %v, want 10.5", RateLimiterMemoryRate, rateFlag.DefValue) - } - } - - burstFlag := fs.Lookup(RateLimiterMemoryBurst) - if burstFlag == nil { - t.Errorf("Flag %s not found", RateLimiterMemoryBurst) - } else { - if burstFlag.DefValue != "100" { - t.Errorf("Flag %s default value = %v, want 100", RateLimiterMemoryBurst, burstFlag.DefValue) - } - } - - expiresFlag := fs.Lookup(RateLimiterMemoryExpires) - if expiresFlag == nil { - t.Errorf("Flag %s not found", RateLimiterMemoryExpires) - } else { - if expiresFlag.DefValue != "5m0s" { - t.Errorf("Flag %s default value = %v, want 5m0s", RateLimiterMemoryExpires, expiresFlag.DefValue) - } - } -} - -func TestRateLimiter_FlagSet_Parse(t *testing.T) { - config := &RateLimiter{ - Enabled: false, - Store: LimiterStoreMemory, - Memory: RateLimiterMemoryStore{ - Rate: 0, - Burst: 0, - ExpiresIn: 3 * time.Minute, - }, - } - - fs := config.FlagSet() - - args := []string{ - "--rate-limiter-enabled", - "--rate-limiter-store", "memory", - "--rate-limiter-memory-rate", "25.5", - "--rate-limiter-memory-burst", "50", - "--rate-limiter-memory-expires", "10m", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } - if config.Store != LimiterStoreMemory { - t.Errorf("Store = %v, want %v", config.Store, LimiterStoreMemory) - } - if config.Memory.Rate != 25.5 { - t.Errorf("Memory.Rate = %v, want 25.5", config.Memory.Rate) - } - if config.Memory.Burst != 50 { - t.Errorf("Memory.Burst = %v, want 50", config.Memory.Burst) - } - if config.Memory.ExpiresIn != 10*time.Minute { - t.Errorf("Memory.ExpiresIn = %v, want 10m", config.Memory.ExpiresIn) - } -} - -func TestDefaultRateLimiter(t *testing.T) { - if DefaultRateLimiter == nil { - t.Fatal("DefaultRateLimiter is nil") - } - - if DefaultRateLimiter.Enabled != false { - t.Errorf("DefaultRateLimiter.Enabled = %v, want false", DefaultRateLimiter.Enabled) - } - if DefaultRateLimiter.Store != LimiterStoreMemory { - t.Errorf("DefaultRateLimiter.Store = %v, want %v", DefaultRateLimiter.Store, LimiterStoreMemory) - } - if DefaultRateLimiter.Memory.Rate != 0 { - t.Errorf("DefaultRateLimiter.Memory.Rate = %v, want 0", DefaultRateLimiter.Memory.Rate) - } - if DefaultRateLimiter.Memory.Burst != 0 { - t.Errorf("DefaultRateLimiter.Memory.Burst = %v, want 0", DefaultRateLimiter.Memory.Burst) - } - if DefaultRateLimiter.Memory.ExpiresIn != 3*time.Minute { - t.Errorf("DefaultRateLimiter.Memory.ExpiresIn = %v, want 3m", DefaultRateLimiter.Memory.ExpiresIn) - } -} - -func TestRateLimiter_FlagSet_DefaultValues(t *testing.T) { - config := &RateLimiter{ - Enabled: true, - Store: LimiterStoreMemory, - Memory: RateLimiterMemoryStore{ - Rate: 5.0, - Burst: 20, - ExpiresIn: 1 * time.Hour, - }, - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(RateLimiterEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } - - storeFlag := fs.Lookup(RateLimiterStoreType) - if storeFlag == nil { - t.Fatal("Store flag not found") - } - if storeFlag.DefValue != "memory" { - t.Errorf("Store flag default = %v, want memory", storeFlag.DefValue) - } - - rateFlag := fs.Lookup(RateLimiterMemoryRate) - if rateFlag == nil { - t.Fatal("Rate flag not found") - } - if rateFlag.DefValue != "5" { - t.Errorf("Rate flag default = %v, want 5", rateFlag.DefValue) - } -} - -func TestRateLimiter_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultRateLimiter - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestRateLimiterStore_String(t *testing.T) { - tests := []struct { - store RateLimiterStore - want string - }{ - {LimiterStoreMemory, "memory"}, - {RateLimiterStore("invalid"), "unknown store: invalid"}, - } - - for _, tt := range tests { - got := tt.store.String() - if got != tt.want { - t.Errorf("RateLimiterStore(%s).String() = %v, want %v", tt.store, got, tt.want) - } - } -} - -func TestRateLimiterStore_Set(t *testing.T) { - tests := []struct { - value string - want RateLimiterStore - wantErr bool - }{ - {"memory", LimiterStoreMemory, false}, - {"MEMORY", LimiterStoreMemory, false}, - {"invalid", RateLimiterStore(""), true}, - } - - for _, tt := range tests { - var store RateLimiterStore - err := store.Set(tt.value) - if tt.wantErr { - if err == nil { - t.Errorf("RateLimiterStore.Set(%q) expected error but got nil", tt.value) - } - } else { - if err != nil { - t.Errorf("RateLimiterStore.Set(%q) unexpected error: %v", tt.value, err) - } - if store != tt.want { - t.Errorf("RateLimiterStore.Set(%q) = %v, want %v", tt.value, store, tt.want) - } - } - } -} - -func TestRateLimiterStore_Type(t *testing.T) { - var store RateLimiterStore - if got := store.Type(); got != "string" { - t.Errorf("RateLimiterStore.Type() = %v, want string", got) - } -} - -func TestNewRateLimiter(t *testing.T) { - config := &RateLimiter{ - Enabled: true, - Store: LimiterStoreMemory, - Memory: RateLimiterMemoryStore{ - Rate: 10, - Burst: 20, - ExpiresIn: 5 * time.Minute, - }, - } - - middleware := NewRateLimiter(config) - if middleware == nil { - t.Fatal("NewRateLimiter() returned nil") - } -} - -func TestNewRateLimiter_DefaultConfig(t *testing.T) { - middleware := NewRateLimiter(DefaultRateLimiter) - if middleware == nil { - t.Fatal("NewRateLimiter() with DefaultRateLimiter returned nil") - } -} - -func TestNewRateLimiter_InvalidStore(t *testing.T) { - config := &RateLimiter{ - Enabled: true, - Store: RateLimiterStore("invalid"), - } - - middleware := NewRateLimiter(config) - if middleware != nil { - t.Error("NewRateLimiter() with invalid store should return nil") - } -} diff --git a/http/middleware/recover.go b/http/middleware/recover.go deleted file mode 100644 index 1a0d07b..0000000 --- a/http/middleware/recover.go +++ /dev/null @@ -1,72 +0,0 @@ -package middleware - -import ( - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" -) - -// Recover holds configuration for panic recovery middleware. -type Recover struct { - // Enabled indicates whether recovery middleware is enabled. - // Optional. Default value true. - Enabled bool - - // Size of the stack to be printed. - // Optional. Default value 4KB. - StackSize int - - // DisableStackAll disables formatting stack traces of all other goroutines - // into buffer after the trace for the current goroutine. - // Optional. Default value false. - DisableStackAll bool - - // DisablePrintStack disables printing stack trace. - // Optional. Default value as false. - DisablePrintStack bool - - // DisableErrorHandler disables the call to centralized HTTPErrorHandler. - // The recovered error is then passed back to upstream middleware, instead of swallowing the error. - // Optional. Default value false. - DisableErrorHandler bool -} - -// DefaultRecover provides default Recover configuration. -var DefaultRecover = &Recover{ - Enabled: true, - StackSize: 4 << 10, // 4 KB - DisableStackAll: false, - DisablePrintStack: false, - DisableErrorHandler: false, -} - -const ( - RecoverEnabled = "recover-enabled" - RecoverStackSize = "recover-stack-size" - RecoverDisableStackAll = "recover-disable-stack-all" - RecoverDisablePrintStack = "recover-disable-print-stack" - RecoverDisableErrorHandler = "recover-disable-error-handler" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (r *Recover) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Recover", pflag.ExitOnError) - - fs.BoolVar(&r.Enabled, RecoverEnabled, r.Enabled, "Enable panic recovery middleware") - fs.IntVar(&r.StackSize, RecoverStackSize, r.StackSize, "Stack size for recovery in bytes") - fs.BoolVar(&r.DisableStackAll, RecoverDisableStackAll, r.DisableStackAll, "Disable stack trace for all errors") - fs.BoolVar(&r.DisablePrintStack, RecoverDisablePrintStack, r.DisablePrintStack, "Disable printing stack trace") - fs.BoolVar(&r.DisableErrorHandler, RecoverDisableErrorHandler, r.DisableErrorHandler, "Disable custom error handler") - - return fs -} - -// NewRecover creates a new panic recovery middleware with the given configuration. -func NewRecover(config *Recover) echo.MiddlewareFunc { - return middleware.RecoverWithConfig(middleware.RecoverConfig{ - StackSize: config.StackSize, - DisableStackAll: config.DisableStackAll, - DisablePrintStack: config.DisablePrintStack, - DisableErrorHandler: config.DisableErrorHandler, - }) -} diff --git a/http/middleware/recover_test.go b/http/middleware/recover_test.go deleted file mode 100644 index 93f5a7d..0000000 --- a/http/middleware/recover_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package middleware - -import ( - "testing" -) - -func TestRecover_FlagSet(t *testing.T) { - config := &Recover{ - Enabled: false, - StackSize: 8192, - DisableStackAll: true, - DisablePrintStack: true, - DisableErrorHandler: true, - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(RecoverEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", RecoverEnabled) - } else { - if enabledFlag.DefValue != "false" { - t.Errorf("Flag %s default value = %v, want false", RecoverEnabled, enabledFlag.DefValue) - } - } - - stackSizeFlag := fs.Lookup(RecoverStackSize) - if stackSizeFlag == nil { - t.Errorf("Flag %s not found", RecoverStackSize) - } else { - if stackSizeFlag.DefValue != "8192" { - t.Errorf("Flag %s default value = %v, want 8192", RecoverStackSize, stackSizeFlag.DefValue) - } - } - - disableStackAllFlag := fs.Lookup(RecoverDisableStackAll) - if disableStackAllFlag == nil { - t.Errorf("Flag %s not found", RecoverDisableStackAll) - } else { - if disableStackAllFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", RecoverDisableStackAll, disableStackAllFlag.DefValue) - } - } -} - -func TestRecover_FlagSet_Parse(t *testing.T) { - config := &Recover{ - Enabled: true, - StackSize: 4096, - DisableStackAll: false, - DisablePrintStack: false, - DisableErrorHandler: false, - } - - fs := config.FlagSet() - - args := []string{ - "--recover-enabled=false", - "--recover-stack-size", "16384", - "--recover-disable-stack-all", - "--recover-disable-print-stack", - "--recover-disable-error-handler", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false", config.Enabled) - } - if config.StackSize != 16384 { - t.Errorf("StackSize = %v, want 16384", config.StackSize) - } - if !config.DisableStackAll { - t.Errorf("DisableStackAll = %v, want true", config.DisableStackAll) - } - if !config.DisablePrintStack { - t.Errorf("DisablePrintStack = %v, want true", config.DisablePrintStack) - } - if !config.DisableErrorHandler { - t.Errorf("DisableErrorHandler = %v, want true", config.DisableErrorHandler) - } -} - -func TestDefaultRecover(t *testing.T) { - if DefaultRecover == nil { - t.Fatal("DefaultRecover is nil") - } - - if DefaultRecover.Enabled != true { - t.Errorf("DefaultRecover.Enabled = %v, want true", DefaultRecover.Enabled) - } - if DefaultRecover.StackSize != 4096 { - t.Errorf("DefaultRecover.StackSize = %v, want 4096", DefaultRecover.StackSize) - } - if DefaultRecover.DisableStackAll != false { - t.Errorf("DefaultRecover.DisableStackAll = %v, want false", DefaultRecover.DisableStackAll) - } - if DefaultRecover.DisablePrintStack != false { - t.Errorf("DefaultRecover.DisablePrintStack = %v, want false", DefaultRecover.DisablePrintStack) - } - if DefaultRecover.DisableErrorHandler != false { - t.Errorf("DefaultRecover.DisableErrorHandler = %v, want false", DefaultRecover.DisableErrorHandler) - } -} - -func TestRecover_FlagSet_DefaultValues(t *testing.T) { - config := &Recover{ - Enabled: false, - StackSize: 2048, - DisableStackAll: true, - DisablePrintStack: false, - DisableErrorHandler: true, - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(RecoverEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "false" { - t.Errorf("Enabled flag default = %v, want false", enabledFlag.DefValue) - } - - stackSizeFlag := fs.Lookup(RecoverStackSize) - if stackSizeFlag == nil { - t.Fatal("StackSize flag not found") - } - if stackSizeFlag.DefValue != "2048" { - t.Errorf("StackSize flag default = %v, want 2048", stackSizeFlag.DefValue) - } - - disableStackAllFlag := fs.Lookup(RecoverDisableStackAll) - if disableStackAllFlag == nil { - t.Fatal("DisableStackAll flag not found") - } - if disableStackAllFlag.DefValue != "true" { - t.Errorf("DisableStackAll flag default = %v, want true", disableStackAllFlag.DefValue) - } -} - -func TestRecover_FlagSet_EnabledByDefault(t *testing.T) { - config := DefaultRecover - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true (default)", config.Enabled) - } -} - -func TestNewRecover(t *testing.T) { - config := &Recover{ - Enabled: true, - StackSize: 8192, - DisableStackAll: true, - DisablePrintStack: false, - DisableErrorHandler: false, - } - - middleware := NewRecover(config) - if middleware == nil { - t.Fatal("NewRecover() returned nil") - } -} - -func TestNewRecover_DefaultConfig(t *testing.T) { - middleware := NewRecover(DefaultRecover) - if middleware == nil { - t.Fatal("NewRecover() with DefaultRecover returned nil") - } -} diff --git a/http/middleware/request_id.go b/http/middleware/request_id.go deleted file mode 100644 index 9a35351..0000000 --- a/http/middleware/request_id.go +++ /dev/null @@ -1,46 +0,0 @@ -package middleware - -import ( - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" -) - -// RequestID holds configuration for request ID middleware. -type RequestID struct { - // Enabled indicates whether request ID middleware is enabled. - // Optional. Default value false. - Enabled bool - - // TargetHeader specifies the header name for the request ID. - // Optional. Default value "X-Request-ID". - TargetHeader string -} - -// DefaultRequestID provides default RequestID configuration. -var DefaultRequestID = &RequestID{ - Enabled: false, - TargetHeader: "X-Request-ID", -} - -const ( - RequestIDEnabled = "request-id-enabled" - RequestIDTargetHeader = "request-id-target-header" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (r *RequestID) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Request ID", pflag.ExitOnError) - - fs.BoolVar(&r.Enabled, RequestIDEnabled, r.Enabled, "Enable request ID middleware") - fs.StringVar(&r.TargetHeader, RequestIDTargetHeader, r.TargetHeader, "Header name for the request ID") - - return fs -} - -// NewRequestID creates a new request ID middleware with the given configuration. -func NewRequestID(config *RequestID) echo.MiddlewareFunc { - return middleware.RequestIDWithConfig(middleware.RequestIDConfig{ - TargetHeader: config.TargetHeader, - }) -} diff --git a/http/middleware/request_id_test.go b/http/middleware/request_id_test.go deleted file mode 100644 index 071b119..0000000 --- a/http/middleware/request_id_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package middleware - -import ( - "testing" -) - -func TestRequestID_FlagSet(t *testing.T) { - config := &RequestID{ - Enabled: true, - TargetHeader: "X-Custom-Request-ID", - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(RequestIDEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", RequestIDEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", RequestIDEnabled, enabledFlag.DefValue) - } - } - - targetHeaderFlag := fs.Lookup(RequestIDTargetHeader) - if targetHeaderFlag == nil { - t.Errorf("Flag %s not found", RequestIDTargetHeader) - } else { - if targetHeaderFlag.DefValue != "X-Custom-Request-ID" { - t.Errorf("Flag %s default value = %v, want X-Custom-Request-ID", RequestIDTargetHeader, targetHeaderFlag.DefValue) - } - } -} - -func TestRequestID_FlagSet_Parse(t *testing.T) { - config := &RequestID{ - Enabled: false, - TargetHeader: "X-Request-ID", - } - - fs := config.FlagSet() - - args := []string{ - "--request-id-enabled", - "--request-id-target-header", "X-Trace-ID", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } - if config.TargetHeader != "X-Trace-ID" { - t.Errorf("TargetHeader = %v, want X-Trace-ID", config.TargetHeader) - } -} - -func TestDefaultRequestID(t *testing.T) { - if DefaultRequestID == nil { - t.Fatal("DefaultRequestID is nil") - } - - if DefaultRequestID.Enabled != false { - t.Errorf("DefaultRequestID.Enabled = %v, want false", DefaultRequestID.Enabled) - } - if DefaultRequestID.TargetHeader != "X-Request-ID" { - t.Errorf("DefaultRequestID.TargetHeader = %v, want X-Request-ID", DefaultRequestID.TargetHeader) - } -} - -func TestRequestID_FlagSet_DefaultValues(t *testing.T) { - config := &RequestID{ - Enabled: true, - TargetHeader: "X-Test-Request-ID", - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(RequestIDEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } - - targetHeaderFlag := fs.Lookup(RequestIDTargetHeader) - if targetHeaderFlag == nil { - t.Fatal("TargetHeader flag not found") - } - if targetHeaderFlag.DefValue != "X-Test-Request-ID" { - t.Errorf("TargetHeader flag default = %v, want X-Test-Request-ID", targetHeaderFlag.DefValue) - } -} - -func TestRequestID_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultRequestID - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestNewRequestID(t *testing.T) { - config := &RequestID{ - Enabled: true, - TargetHeader: "X-Custom-ID", - } - - middleware := NewRequestID(config) - if middleware == nil { - t.Fatal("NewRequestID() returned nil") - } -} - -func TestNewRequestID_DefaultConfig(t *testing.T) { - middleware := NewRequestID(DefaultRequestID) - if middleware == nil { - t.Fatal("NewRequestID() with DefaultRequestID returned nil") - } -} diff --git a/http/middleware/request_logger.go b/http/middleware/request_logger.go deleted file mode 100644 index 67f61d2..0000000 --- a/http/middleware/request_logger.go +++ /dev/null @@ -1,90 +0,0 @@ -package middleware - -import ( - "strconv" - "time" - - "github.com/alexferl/golib/logger" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" -) - -// RequestLogger holds configuration for logging middleware. -type RequestLogger struct { - // Enabled indicates whether logging middleware is enabled. - // Optional. Default value false. - Enabled bool - - // Logger instance from the logger submodule. - // Optional. If nil, a default logger will be created. - Logger *logger.Logger -} - -// DefaultLogger provides default RequestLogger configuration. -var DefaultLogger = &RequestLogger{ - Enabled: false, - Logger: nil, -} - -const ( - RequestLoggerEnabled = "request-logger-enabled" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (l *RequestLogger) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Request Logger", pflag.ExitOnError) - - fs.BoolVar(&l.Enabled, RequestLoggerEnabled, l.Enabled, "Enable request logging middleware") - - return fs -} - -// NewRequestLogger creates a new request logging middleware with the given configuration. -func NewRequestLogger(config *RequestLogger) echo.MiddlewareFunc { - var log *logger.Logger - if config.Logger != nil { - log = config.Logger - } else { - defaultLogger, err := logger.New(logger.DefaultConfig) - if err != nil { - panic(err) - } - log = defaultLogger - } - - return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ - HandleError: true, - LogRequestID: true, - LogRemoteIP: true, - LogHost: true, - LogMethod: true, - LogURI: true, - LogUserAgent: true, - LogStatus: true, - LogError: true, - LogLatency: true, - LogContentLength: true, - LogResponseSize: true, - LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error { - i, _ := strconv.Atoi(v.ContentLength) - log.Info(). - Str("time", time.Now().Format(time.RFC3339Nano)). - Str("id", v.RequestID). - Str("remote_ip", v.RemoteIP). - Str("host", v.Host). - Str("method", v.Method). - Str("uri", v.URI). - Str("user_agent", v.UserAgent). - Int("status", v.Status). - Err(v.Error). - Int64("latency", v.Latency.Nanoseconds()). - Str("latency_human", v.Latency.String()). - Int64("bytes_in", int64(i)). - Int64("bytes_out", v.ResponseSize). - Send() - - return nil - }, - }) -} diff --git a/http/middleware/request_logger_test.go b/http/middleware/request_logger_test.go deleted file mode 100644 index e768d4c..0000000 --- a/http/middleware/request_logger_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package middleware - -import ( - "testing" - - "github.com/alexferl/golib/logger" -) - -func TestRequestLogger_FlagSet(t *testing.T) { - config := &RequestLogger{ - Enabled: true, - Logger: nil, - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(RequestLoggerEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", RequestLoggerEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", RequestLoggerEnabled, enabledFlag.DefValue) - } - } -} - -func TestRequestLogger_FlagSet_Parse(t *testing.T) { - config := &RequestLogger{ - Enabled: false, - Logger: nil, - } - - fs := config.FlagSet() - - args := []string{ - "--request-logger-enabled", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } -} - -func TestDefaultLogger(t *testing.T) { - if DefaultLogger == nil { - t.Fatal("DefaultLogger is nil") - } - - if DefaultLogger.Enabled != false { - t.Errorf("DefaultLogger.Enabled = %v, want false", DefaultLogger.Enabled) - } - if DefaultLogger.Logger != nil { - t.Errorf("DefaultLogger.Logger = %v, want nil", DefaultLogger.Logger) - } -} - -func TestRequestLogger_FlagSet_DefaultValues(t *testing.T) { - config := &RequestLogger{ - Enabled: true, - Logger: nil, - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(RequestLoggerEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } -} - -func TestRequestLogger_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultLogger - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestNewRequestLogger(t *testing.T) { - config := &RequestLogger{ - Enabled: true, - Logger: nil, - } - - middleware := NewRequestLogger(config) - if middleware == nil { - t.Fatal("NewRequestLogger() returned nil") - } -} - -func TestNewRequestLogger_WithCustomLogger(t *testing.T) { - customLogger, err := logger.New(&logger.Config{ - LogLevel: logger.LevelInfo, - LogFormat: logger.FormatJSON, - LogOutput: logger.OutputStdOut, - }) - if err != nil { - t.Fatalf("Failed to create custom logger: %v", err) - } - - config := &RequestLogger{ - Enabled: true, - Logger: customLogger, - } - - middleware := NewRequestLogger(config) - if middleware == nil { - t.Fatal("NewRequestLogger() with custom logger returned nil") - } -} - -func TestNewRequestLogger_DefaultConfig(t *testing.T) { - middleware := NewRequestLogger(DefaultLogger) - if middleware == nil { - t.Fatal("NewRequestLogger() with DefaultLogger returned nil") - } -} diff --git a/http/middleware/secure.go b/http/middleware/secure.go deleted file mode 100644 index abf9776..0000000 --- a/http/middleware/secure.go +++ /dev/null @@ -1,153 +0,0 @@ -package middleware - -import ( - secure "github.com/alexferl/echo-secure" - "github.com/labstack/echo/v4" - "github.com/spf13/pflag" -) - -// StrictTransportSecurity holds configuration for HSTS. -type StrictTransportSecurity struct { - // MaxAge specifies the max age for HSTS in seconds. - // Optional. Default value from secure.DefaultConfig. - MaxAge int - - // ExcludeSubdomains indicates whether to exclude subdomains from HSTS. - // Optional. Default value from secure.DefaultConfig. - ExcludeSubdomains bool - - // PreloadEnabled indicates whether HSTS preload is enabled. - // Optional. Default value from secure.DefaultConfig. - PreloadEnabled bool -} - -// Secure holds configuration for security middleware. -type Secure struct { - // Enabled indicates whether security middleware is enabled. - // Optional. Default value false. - Enabled bool - - // ContentSecurityPolicy specifies the CSP header value. - // Optional. Default value from secure.DefaultConfig. - ContentSecurityPolicy string - - // ContentSecurityPolicyReportOnly indicates whether CSP is in report-only mode. - // Optional. Default value from secure.DefaultConfig. - ContentSecurityPolicyReportOnly bool - - // CrossOriginEmbedderPolicy specifies the COEP header value. - // Optional. Default value from secure.DefaultConfig. - CrossOriginEmbedderPolicy string - - // CrossOriginOpenerPolicy specifies the COOP header value. - // Optional. Default value from secure.DefaultConfig. - CrossOriginOpenerPolicy string - - // CrossOriginResourcePolicy specifies the CORP header value. - // Optional. Default value from secure.DefaultConfig. - CrossOriginResourcePolicy string - - // PermissionsPolicy specifies the Permissions-Policy header value. - // Optional. Default value from secure.DefaultConfig. - PermissionsPolicy string - - // ReferrerPolicy specifies the Referrer-Policy header value. - // Optional. Default value from secure.DefaultConfig. - ReferrerPolicy string - - // Server specifies the Server header value. - // Optional. Default value from secure.DefaultConfig. - Server string - - // StrictTransportSecurity holds HSTS configuration. - // Optional. Default value from secure.DefaultConfig. - StrictTransportSecurity StrictTransportSecurity - - // XContentTypeOptions specifies the X-Content-Type-Options header value. - // Optional. Default value from secure.DefaultConfig. - XContentTypeOptions string - - // XFrameOptions specifies the X-Frame-Options header value. - // Optional. Default value from secure.DefaultConfig. - XFrameOptions string -} - -// DefaultSecure provides default Secure configuration. -var DefaultSecure = &Secure{ - Enabled: false, - ContentSecurityPolicy: secure.DefaultConfig.ContentSecurityPolicy, - ContentSecurityPolicyReportOnly: secure.DefaultConfig.ContentSecurityPolicyReportOnly, - CrossOriginEmbedderPolicy: secure.DefaultConfig.CrossOriginEmbedderPolicy, - CrossOriginOpenerPolicy: secure.DefaultConfig.CrossOriginOpenerPolicy, - CrossOriginResourcePolicy: secure.DefaultConfig.CrossOriginResourcePolicy, - PermissionsPolicy: secure.DefaultConfig.PermissionsPolicy, - ReferrerPolicy: secure.DefaultConfig.ReferrerPolicy, - Server: secure.DefaultConfig.Server, - StrictTransportSecurity: StrictTransportSecurity{ - MaxAge: secure.DefaultConfig.StrictTransportSecurity.MaxAge, - ExcludeSubdomains: secure.DefaultConfig.StrictTransportSecurity.ExcludeSubdomains, - PreloadEnabled: secure.DefaultConfig.StrictTransportSecurity.PreloadEnabled, - }, - XContentTypeOptions: secure.DefaultConfig.XContentTypeOptions, - XFrameOptions: secure.DefaultConfig.XFrameOptions, -} - -const ( - SecureEnabled = "secure-enabled" - SecureContentSecurityPolicy = "secure-content-security-policy" - SecureContentSecurityPolicyReportOnly = "secure-content-security-policy-report-only" - SecureCrossOriginEmbedderPolicy = "secure-cross-origin-embedder-policy" - SecureCrossOriginOpenerPolicy = "secure-cross-origin-opener-policy" - SecureCrossOriginResourcePolicy = "secure-cross-origin-resource-policy" - SecurePermissionsPolicy = "secure-permissions-policy" - SecureReferrerPolicy = "secure-referrer-policy" - SecureServer = "secure-server" - SecureSTSMaxAge = "secure-sts-max-age" - SecureSTSExcludeSubdomains = "secure-sts-exclude-subdomains" - SecureSTSPreloadEnabled = "secure-sts-preload-enabled" - SecureXContentTypeOptions = "secure-x-content-type-options" - SecureXFrameOptions = "secure-x-frame-options" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (s *Secure) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Secure", pflag.ExitOnError) - - fs.BoolVar(&s.Enabled, SecureEnabled, s.Enabled, "Enable security middleware") - fs.StringVar(&s.ContentSecurityPolicy, SecureContentSecurityPolicy, s.ContentSecurityPolicy, "Content Security Policy header value") - fs.BoolVar(&s.ContentSecurityPolicyReportOnly, SecureContentSecurityPolicyReportOnly, s.ContentSecurityPolicyReportOnly, "Enable CSP report-only mode") - fs.StringVar(&s.CrossOriginEmbedderPolicy, SecureCrossOriginEmbedderPolicy, s.CrossOriginEmbedderPolicy, "Cross-Origin-Embedder-Policy header value") - fs.StringVar(&s.CrossOriginOpenerPolicy, SecureCrossOriginOpenerPolicy, s.CrossOriginOpenerPolicy, "Cross-Origin-Opener-Policy header value") - fs.StringVar(&s.CrossOriginResourcePolicy, SecureCrossOriginResourcePolicy, s.CrossOriginResourcePolicy, "Cross-Origin-Resource-Policy header value") - fs.StringVar(&s.PermissionsPolicy, SecurePermissionsPolicy, s.PermissionsPolicy, "Permissions-Policy header value") - fs.StringVar(&s.ReferrerPolicy, SecureReferrerPolicy, s.ReferrerPolicy, "Referrer-Policy header value") - fs.StringVar(&s.Server, SecureServer, s.Server, "Server header value") - fs.IntVar(&s.StrictTransportSecurity.MaxAge, SecureSTSMaxAge, s.StrictTransportSecurity.MaxAge, "HSTS max age in seconds") - fs.BoolVar(&s.StrictTransportSecurity.ExcludeSubdomains, SecureSTSExcludeSubdomains, s.StrictTransportSecurity.ExcludeSubdomains, "Exclude subdomains from HSTS") - fs.BoolVar(&s.StrictTransportSecurity.PreloadEnabled, SecureSTSPreloadEnabled, s.StrictTransportSecurity.PreloadEnabled, "Enable HSTS preload") - fs.StringVar(&s.XContentTypeOptions, SecureXContentTypeOptions, s.XContentTypeOptions, "X-Content-Type-Options header value") - fs.StringVar(&s.XFrameOptions, SecureXFrameOptions, s.XFrameOptions, "X-Frame-Options header value") - - return fs -} - -// NewSecure creates a new security middleware with the given configuration. -func NewSecure(config *Secure) echo.MiddlewareFunc { - return secure.New(secure.Config{ - ContentSecurityPolicy: config.ContentSecurityPolicy, - ContentSecurityPolicyReportOnly: config.ContentSecurityPolicyReportOnly, - CrossOriginEmbedderPolicy: config.CrossOriginEmbedderPolicy, - CrossOriginOpenerPolicy: config.CrossOriginOpenerPolicy, - CrossOriginResourcePolicy: config.CrossOriginResourcePolicy, - PermissionsPolicy: config.PermissionsPolicy, - ReferrerPolicy: config.ReferrerPolicy, - Server: config.Server, - StrictTransportSecurity: secure.StrictTransportSecurity{ - MaxAge: config.StrictTransportSecurity.MaxAge, - ExcludeSubdomains: config.StrictTransportSecurity.ExcludeSubdomains, - PreloadEnabled: config.StrictTransportSecurity.PreloadEnabled, - }, - XContentTypeOptions: config.XContentTypeOptions, - XFrameOptions: config.XFrameOptions, - }) -} diff --git a/http/middleware/secure_test.go b/http/middleware/secure_test.go deleted file mode 100644 index 27809c1..0000000 --- a/http/middleware/secure_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package middleware - -import ( - secure "github.com/alexferl/echo-secure" - "testing" -) - -func TestSecure_FlagSet(t *testing.T) { - config := &Secure{ - Enabled: true, - ContentSecurityPolicy: "default-src 'self'", - ContentSecurityPolicyReportOnly: true, - CrossOriginEmbedderPolicy: "require-corp", - CrossOriginOpenerPolicy: "same-origin", - CrossOriginResourcePolicy: "cross-origin", - PermissionsPolicy: "geolocation=()", - ReferrerPolicy: "strict-origin", - Server: "MyServer/1.0", - StrictTransportSecurity: StrictTransportSecurity{ - MaxAge: 31536000, - ExcludeSubdomains: true, - PreloadEnabled: true, - }, - XContentTypeOptions: "nosniff", - XFrameOptions: "DENY", - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(SecureEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", SecureEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", SecureEnabled, enabledFlag.DefValue) - } - } - - cspFlag := fs.Lookup(SecureContentSecurityPolicy) - if cspFlag == nil { - t.Errorf("Flag %s not found", SecureContentSecurityPolicy) - } else { - if cspFlag.DefValue != "default-src 'self'" { - t.Errorf("Flag %s default value = %v, want default-src 'self'", SecureContentSecurityPolicy, cspFlag.DefValue) - } - } - - stsMaxAgeFlag := fs.Lookup(SecureSTSMaxAge) - if stsMaxAgeFlag == nil { - t.Errorf("Flag %s not found", SecureSTSMaxAge) - } else { - if stsMaxAgeFlag.DefValue != "31536000" { - t.Errorf("Flag %s default value = %v, want 31536000", SecureSTSMaxAge, stsMaxAgeFlag.DefValue) - } - } -} - -func TestSecure_FlagSet_Parse(t *testing.T) { - config := &Secure{ - Enabled: false, - ContentSecurityPolicy: "", - ContentSecurityPolicyReportOnly: false, - CrossOriginEmbedderPolicy: "", - CrossOriginOpenerPolicy: "", - CrossOriginResourcePolicy: "", - PermissionsPolicy: "", - ReferrerPolicy: "", - Server: "", - StrictTransportSecurity: StrictTransportSecurity{ - MaxAge: 0, - ExcludeSubdomains: false, - PreloadEnabled: false, - }, - XContentTypeOptions: "", - XFrameOptions: "", - } - - fs := config.FlagSet() - - args := []string{ - "--secure-enabled", - "--secure-content-security-policy", "default-src 'none'", - "--secure-content-security-policy-report-only", - "--secure-cross-origin-embedder-policy", "credentialless", - "--secure-cross-origin-opener-policy", "same-origin-allow-popups", - "--secure-cross-origin-resource-policy", "same-site", - "--secure-permissions-policy", "camera=()", - "--secure-referrer-policy", "no-referrer", - "--secure-server", "TestServer/2.0", - "--secure-sts-max-age", "63072000", - "--secure-sts-exclude-subdomains", - "--secure-sts-preload-enabled", - "--secure-x-content-type-options", "nosniff", - "--secure-x-frame-options", "SAMEORIGIN", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } - if config.ContentSecurityPolicy != "default-src 'none'" { - t.Errorf("ContentSecurityPolicy = %v, want default-src 'none'", config.ContentSecurityPolicy) - } - if !config.ContentSecurityPolicyReportOnly { - t.Errorf("ContentSecurityPolicyReportOnly = %v, want true", config.ContentSecurityPolicyReportOnly) - } - if config.CrossOriginEmbedderPolicy != "credentialless" { - t.Errorf("CrossOriginEmbedderPolicy = %v, want credentialless", config.CrossOriginEmbedderPolicy) - } - if config.CrossOriginOpenerPolicy != "same-origin-allow-popups" { - t.Errorf("CrossOriginOpenerPolicy = %v, want same-origin-allow-popups", config.CrossOriginOpenerPolicy) - } - if config.CrossOriginResourcePolicy != "same-site" { - t.Errorf("CrossOriginResourcePolicy = %v, want same-site", config.CrossOriginResourcePolicy) - } - if config.PermissionsPolicy != "camera=()" { - t.Errorf("PermissionsPolicy = %v, want camera=()", config.PermissionsPolicy) - } - if config.ReferrerPolicy != "no-referrer" { - t.Errorf("ReferrerPolicy = %v, want no-referrer", config.ReferrerPolicy) - } - if config.Server != "TestServer/2.0" { - t.Errorf("Server = %v, want TestServer/2.0", config.Server) - } - if config.StrictTransportSecurity.MaxAge != 63072000 { - t.Errorf("StrictTransportSecurity.MaxAge = %v, want 63072000", config.StrictTransportSecurity.MaxAge) - } - if !config.StrictTransportSecurity.ExcludeSubdomains { - t.Errorf("StrictTransportSecurity.ExcludeSubdomains = %v, want true", config.StrictTransportSecurity.ExcludeSubdomains) - } - if !config.StrictTransportSecurity.PreloadEnabled { - t.Errorf("StrictTransportSecurity.PreloadEnabled = %v, want true", config.StrictTransportSecurity.PreloadEnabled) - } - if config.XContentTypeOptions != "nosniff" { - t.Errorf("XContentTypeOptions = %v, want nosniff", config.XContentTypeOptions) - } - if config.XFrameOptions != "SAMEORIGIN" { - t.Errorf("XFrameOptions = %v, want SAMEORIGIN", config.XFrameOptions) - } -} - -func TestDefaultSecure(t *testing.T) { - if DefaultSecure == nil { - t.Fatal("DefaultSecure is nil") - } - - if DefaultSecure.Enabled != false { - t.Errorf("DefaultSecure.Enabled = %v, want false", DefaultSecure.Enabled) - } - if DefaultSecure.ContentSecurityPolicy != secure.DefaultConfig.ContentSecurityPolicy { - t.Errorf("DefaultSecure.ContentSecurityPolicy = %v, want %v", DefaultSecure.ContentSecurityPolicy, secure.DefaultConfig.ContentSecurityPolicy) - } - if DefaultSecure.ContentSecurityPolicyReportOnly != secure.DefaultConfig.ContentSecurityPolicyReportOnly { - t.Errorf("DefaultSecure.ContentSecurityPolicyReportOnly = %v, want %v", DefaultSecure.ContentSecurityPolicyReportOnly, secure.DefaultConfig.ContentSecurityPolicyReportOnly) - } - if DefaultSecure.StrictTransportSecurity.MaxAge != secure.DefaultConfig.StrictTransportSecurity.MaxAge { - t.Errorf("DefaultSecure.StrictTransportSecurity.MaxAge = %v, want %v", DefaultSecure.StrictTransportSecurity.MaxAge, secure.DefaultConfig.StrictTransportSecurity.MaxAge) - } -} - -func TestSecure_FlagSet_DefaultValues(t *testing.T) { - config := &Secure{ - Enabled: true, - ContentSecurityPolicy: "test-csp", - Server: "TestServer", - StrictTransportSecurity: StrictTransportSecurity{ - MaxAge: 1800, - ExcludeSubdomains: true, - PreloadEnabled: false, - }, - XFrameOptions: "DENY", - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(SecureEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } - - cspFlag := fs.Lookup(SecureContentSecurityPolicy) - if cspFlag == nil { - t.Fatal("ContentSecurityPolicy flag not found") - } - if cspFlag.DefValue != "test-csp" { - t.Errorf("ContentSecurityPolicy flag default = %v, want test-csp", cspFlag.DefValue) - } - - serverFlag := fs.Lookup(SecureServer) - if serverFlag == nil { - t.Fatal("Server flag not found") - } - if serverFlag.DefValue != "TestServer" { - t.Errorf("Server flag default = %v, want TestServer", serverFlag.DefValue) - } -} - -func TestSecure_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultSecure - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestNewSecure(t *testing.T) { - config := &Secure{ - Enabled: true, - ContentSecurityPolicy: "default-src 'self'", - ContentSecurityPolicyReportOnly: true, - CrossOriginEmbedderPolicy: "require-corp", - CrossOriginOpenerPolicy: "same-origin", - CrossOriginResourcePolicy: "cross-origin", - PermissionsPolicy: "geolocation=()", - ReferrerPolicy: "strict-origin", - Server: "MyServer/1.0", - StrictTransportSecurity: StrictTransportSecurity{ - MaxAge: 31536000, - ExcludeSubdomains: true, - PreloadEnabled: true, - }, - XContentTypeOptions: "nosniff", - XFrameOptions: "DENY", - } - - middleware := NewSecure(config) - if middleware == nil { - t.Fatal("NewSecure() returned nil") - } -} - -func TestNewSecure_DefaultConfig(t *testing.T) { - middleware := NewSecure(DefaultSecure) - if middleware == nil { - t.Fatal("NewSecure() with DefaultSecure returned nil") - } -} diff --git a/http/middleware/session.go b/http/middleware/session.go deleted file mode 100644 index 76f3278..0000000 --- a/http/middleware/session.go +++ /dev/null @@ -1,104 +0,0 @@ -package middleware - -import ( - "fmt" - "strings" - - "github.com/gorilla/sessions" - "github.com/labstack/echo-contrib/session" - "github.com/labstack/echo/v4" - "github.com/spf13/pflag" -) - -// SessionCookieStore holds configuration for cookie-based session storage. -type SessionCookieStore struct { - // Secret specifies the secret key for cookie sessions. - // Optional. Default value "changeme". - Secret string -} - -type SessionStore string - -const ( - sessionStoreCookie = "cookie" -) - -const ( - SessionStoreCookie SessionStore = sessionStoreCookie -) - -var SessionStores = []string{sessionStoreCookie} - -func (s *SessionStore) String() string { - switch *s { - case SessionStoreCookie: - return sessionStoreCookie - default: - return fmt.Sprintf("unknown store: %s", *s) - } -} - -func (s *SessionStore) Set(value string) error { - switch strings.ToLower(value) { - case sessionStoreCookie: - *s = SessionStoreCookie - return nil - default: - return fmt.Errorf("invalid session store: %s (must be one of: %s)", value, strings.Join(SessionStores, ", ")) - } -} - -func (s *SessionStore) Type() string { - return "string" -} - -// Session holds configuration for session middleware. -type Session struct { - // Enabled indicates whether session middleware is enabled. - // Optional. Default value false. - Enabled bool - - // Store specifies the session store type. - // Optional. Default value "cookie". - Store SessionStore - - // Cookie holds cookie store configuration. - // Optional. Default value with secret "changeme". - Cookie SessionCookieStore -} - -// DefaultSession provides default Session configuration. -var DefaultSession = &Session{ - Enabled: false, - Store: sessionStoreCookie, - Cookie: SessionCookieStore{ - Secret: "changeme", - }, -} - -const ( - SessionEnabled = "session-enabled" - SessionStoreType = "session-store" // Changed from SessionStore - SessionCookieSecret = "session-cookie-secret" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (s *Session) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Session", pflag.ExitOnError) - - fs.BoolVar(&s.Enabled, SessionEnabled, s.Enabled, "Enable session middleware") - fs.Var(&s.Store, SessionStoreType, fmt.Sprintf("Session store type\nValues: %s", strings.Join(SessionStores, ", "))) - fs.StringVar(&s.Cookie.Secret, SessionCookieSecret, s.Cookie.Secret, "Secret key for cookie sessions") - - return fs -} - -// NewSession creates a new session middleware with the given configuration. -func NewSession(config *Session) echo.MiddlewareFunc { - switch config.Store { - case SessionStoreCookie: - return session.Middleware(sessions.NewCookieStore([]byte(config.Cookie.Secret))) - } - - return nil -} diff --git a/http/middleware/session_test.go b/http/middleware/session_test.go deleted file mode 100644 index 4ce988c..0000000 --- a/http/middleware/session_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package middleware - -import ( - "testing" -) - -func TestSession_FlagSet(t *testing.T) { - config := &Session{ - Enabled: true, - Store: SessionStoreCookie, - Cookie: SessionCookieStore{ - Secret: "testsecret", - }, - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(SessionEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", SessionEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", SessionEnabled, enabledFlag.DefValue) - } - } - - storeFlag := fs.Lookup(SessionStoreType) - if storeFlag == nil { - t.Errorf("Flag %s not found", SessionStoreType) - } else { - if storeFlag.DefValue != "cookie" { - t.Errorf("Flag %s default value = %v, want cookie", SessionStoreType, storeFlag.DefValue) - } - } - - cookieSecretFlag := fs.Lookup(SessionCookieSecret) - if cookieSecretFlag == nil { - t.Errorf("Flag %s not found", SessionCookieSecret) - } else { - if cookieSecretFlag.DefValue != "testsecret" { - t.Errorf("Flag %s default value = %v, want testsecret", SessionCookieSecret, cookieSecretFlag.DefValue) - } - } -} - -func TestSession_FlagSet_Parse(t *testing.T) { - config := &Session{ - Enabled: false, - Store: SessionStoreCookie, - Cookie: SessionCookieStore{ - Secret: "changeme", - }, - } - - fs := config.FlagSet() - - args := []string{ - "--session-enabled", - "--session-store", "cookie", - "--session-cookie-secret", "newsecret", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } - if config.Store != SessionStoreCookie { - t.Errorf("Store = %v, want %v", config.Store, SessionStoreCookie) - } - if config.Cookie.Secret != "newsecret" { - t.Errorf("Cookie.Secret = %v, want newsecret", config.Cookie.Secret) - } -} - -func TestDefaultSession(t *testing.T) { - if DefaultSession == nil { - t.Fatal("DefaultSession is nil") - } - - if DefaultSession.Enabled != false { - t.Errorf("DefaultSession.Enabled = %v, want false", DefaultSession.Enabled) - } - if DefaultSession.Store != SessionStoreCookie { - t.Errorf("DefaultSession.Store = %v, want %v", DefaultSession.Store, SessionStoreCookie) - } - if DefaultSession.Cookie.Secret != "changeme" { - t.Errorf("DefaultSession.Cookie.Secret = %v, want changeme", DefaultSession.Cookie.Secret) - } -} - -func TestSession_FlagSet_DefaultValues(t *testing.T) { - config := &Session{ - Enabled: true, - Store: SessionStoreCookie, - Cookie: SessionCookieStore{ - Secret: "testsecret123", - }, - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(SessionEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } - - storeFlag := fs.Lookup(SessionStoreType) - if storeFlag == nil { - t.Fatal("Store flag not found") - } - if storeFlag.DefValue != "cookie" { - t.Errorf("Store flag default = %v, want cookie", storeFlag.DefValue) - } - - cookieSecretFlag := fs.Lookup(SessionCookieSecret) - if cookieSecretFlag == nil { - t.Fatal("CookieSecret flag not found") - } - if cookieSecretFlag.DefValue != "testsecret123" { - t.Errorf("CookieSecret flag default = %v, want testsecret123", cookieSecretFlag.DefValue) - } -} - -func TestSession_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultSession - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestSessionStore_String(t *testing.T) { - tests := []struct { - store SessionStore - want string - }{ - {SessionStoreCookie, "cookie"}, - {SessionStore("invalid"), "unknown store: invalid"}, - } - - for _, tt := range tests { - got := tt.store.String() - if got != tt.want { - t.Errorf("SessionStore(%s).String() = %v, want %v", tt.store, got, tt.want) - } - } -} - -func TestSessionStore_Set(t *testing.T) { - tests := []struct { - value string - want SessionStore - wantErr bool - }{ - {"cookie", SessionStoreCookie, false}, - {"COOKIE", SessionStoreCookie, false}, - {"invalid", SessionStore(""), true}, - } - - for _, tt := range tests { - var store SessionStore - err := store.Set(tt.value) - if tt.wantErr { - if err == nil { - t.Errorf("SessionStore.Set(%q) expected error but got nil", tt.value) - } - } else { - if err != nil { - t.Errorf("SessionStore.Set(%q) unexpected error: %v", tt.value, err) - } - if store != tt.want { - t.Errorf("SessionStore.Set(%q) = %v, want %v", tt.value, store, tt.want) - } - } - } -} - -func TestSessionStore_Type(t *testing.T) { - var store SessionStore - if got := store.Type(); got != "string" { - t.Errorf("SessionStore.Type() = %v, want string", got) - } -} - -func TestNewSession(t *testing.T) { - config := &Session{ - Enabled: true, - Store: SessionStoreCookie, - Cookie: SessionCookieStore{ - Secret: "testsecret", - }, - } - - middleware := NewSession(config) - if middleware == nil { - t.Fatal("NewSession() returned nil") - } -} - -func TestNewSession_DefaultConfig(t *testing.T) { - middleware := NewSession(DefaultSession) - if middleware == nil { - t.Fatal("NewSession() with DefaultSession returned nil") - } -} - -func TestNewSession_InvalidStore(t *testing.T) { - config := &Session{ - Enabled: true, - Store: SessionStore("invalid"), - } - - middleware := NewSession(config) - if middleware != nil { - t.Error("NewSession() with invalid store should return nil") - } -} diff --git a/http/middleware/static.go b/http/middleware/static.go deleted file mode 100644 index 1a75c34..0000000 --- a/http/middleware/static.go +++ /dev/null @@ -1,78 +0,0 @@ -package middleware - -import ( - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" -) - -// Static holds configuration for static file serving middleware. -type Static struct { - // Enabled indicates whether static file serving middleware is enabled. - // Optional. Default value false. - Enabled bool - - // Root specifies the root directory for static files. - // Optional. Default value "". - Root string - - // Index specifies the index file name. - // Optional. Default value "index.html". - Index string - - // HTML5 indicates whether to enable HTML5 mode. - // Optional. Default value false. - HTML5 bool - - // Browse indicates whether directory browsing is enabled. - // Optional. Default value false. - Browse bool - - // IgnoreBase indicates whether to ignore base path. - // Optional. Default value false. - IgnoreBase bool -} - -// DefaultStatic provides default Static configuration. -var DefaultStatic = &Static{ - Enabled: false, - Root: "", - Index: "index.html", - HTML5: false, - Browse: false, - IgnoreBase: false, -} - -const ( - StaticEnabled = "static-enabled" - StaticRoot = "static-root" - StaticIndex = "static-index" - StaticHTML5 = "static-html5" - StaticBrowse = "static-browse" - StaticIgnoreBase = "static-ignore-base" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (s *Static) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Static", pflag.ExitOnError) - - fs.BoolVar(&s.Enabled, StaticEnabled, s.Enabled, "Enable static file serving middleware") - fs.StringVar(&s.Root, StaticRoot, s.Root, "Root directory for static files") - fs.StringVar(&s.Index, StaticIndex, s.Index, "Index file name") - fs.BoolVar(&s.HTML5, StaticHTML5, s.HTML5, "Enable HTML5 mode") - fs.BoolVar(&s.Browse, StaticBrowse, s.Browse, "Enable directory browsing") - fs.BoolVar(&s.IgnoreBase, StaticIgnoreBase, s.IgnoreBase, "Ignore base path") - - return fs -} - -// NewStatic creates a new static file serving middleware with the given configuration. -func NewStatic(config *Static) echo.MiddlewareFunc { - return middleware.StaticWithConfig(middleware.StaticConfig{ - Root: config.Root, - Index: config.Index, - HTML5: config.HTML5, - Browse: config.Browse, - IgnoreBase: config.IgnoreBase, - }) -} diff --git a/http/middleware/static_test.go b/http/middleware/static_test.go deleted file mode 100644 index 94b2617..0000000 --- a/http/middleware/static_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package middleware - -import ( - "testing" -) - -func TestStatic_FlagSet(t *testing.T) { - config := &Static{ - Enabled: true, - Root: "/var/www", - Index: "home.html", - HTML5: true, - Browse: true, - IgnoreBase: true, - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(StaticEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", StaticEnabled) - } else { - if enabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", StaticEnabled, enabledFlag.DefValue) - } - } - - rootFlag := fs.Lookup(StaticRoot) - if rootFlag == nil { - t.Errorf("Flag %s not found", StaticRoot) - } else { - if rootFlag.DefValue != "/var/www" { - t.Errorf("Flag %s default value = %v, want /var/www", StaticRoot, rootFlag.DefValue) - } - } - - indexFlag := fs.Lookup(StaticIndex) - if indexFlag == nil { - t.Errorf("Flag %s not found", StaticIndex) - } else { - if indexFlag.DefValue != "home.html" { - t.Errorf("Flag %s default value = %v, want home.html", StaticIndex, indexFlag.DefValue) - } - } -} - -func TestStatic_FlagSet_Parse(t *testing.T) { - config := &Static{ - Enabled: false, - Root: "", - Index: "index.html", - HTML5: false, - Browse: false, - IgnoreBase: false, - } - - fs := config.FlagSet() - - args := []string{ - "--static-enabled", - "--static-root", "/public", - "--static-index", "main.html", - "--static-html5", - "--static-browse", - "--static-ignore-base", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true", config.Enabled) - } - if config.Root != "/public" { - t.Errorf("Root = %v, want /public", config.Root) - } - if config.Index != "main.html" { - t.Errorf("Index = %v, want main.html", config.Index) - } - if !config.HTML5 { - t.Errorf("HTML5 = %v, want true", config.HTML5) - } - if !config.Browse { - t.Errorf("Browse = %v, want true", config.Browse) - } - if !config.IgnoreBase { - t.Errorf("IgnoreBase = %v, want true", config.IgnoreBase) - } -} - -func TestDefaultStatic(t *testing.T) { - if DefaultStatic == nil { - t.Fatal("DefaultStatic is nil") - } - - if DefaultStatic.Enabled != false { - t.Errorf("DefaultStatic.Enabled = %v, want false", DefaultStatic.Enabled) - } - if DefaultStatic.Root != "" { - t.Errorf("DefaultStatic.Root = %v, want empty string", DefaultStatic.Root) - } - if DefaultStatic.Index != "index.html" { - t.Errorf("DefaultStatic.Index = %v, want index.html", DefaultStatic.Index) - } - if DefaultStatic.HTML5 != false { - t.Errorf("DefaultStatic.HTML5 = %v, want false", DefaultStatic.HTML5) - } - if DefaultStatic.Browse != false { - t.Errorf("DefaultStatic.Browse = %v, want false", DefaultStatic.Browse) - } - if DefaultStatic.IgnoreBase != false { - t.Errorf("DefaultStatic.IgnoreBase = %v, want false", DefaultStatic.IgnoreBase) - } -} - -func TestStatic_FlagSet_DefaultValues(t *testing.T) { - config := &Static{ - Enabled: true, - Root: "/assets", - Index: "start.html", - HTML5: false, - Browse: true, - IgnoreBase: false, - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(StaticEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "true" { - t.Errorf("Enabled flag default = %v, want true", enabledFlag.DefValue) - } - - rootFlag := fs.Lookup(StaticRoot) - if rootFlag == nil { - t.Fatal("Root flag not found") - } - if rootFlag.DefValue != "/assets" { - t.Errorf("Root flag default = %v, want /assets", rootFlag.DefValue) - } - - indexFlag := fs.Lookup(StaticIndex) - if indexFlag == nil { - t.Fatal("Index flag not found") - } - if indexFlag.DefValue != "start.html" { - t.Errorf("Index flag default = %v, want start.html", indexFlag.DefValue) - } -} - -func TestStatic_FlagSet_DisabledByDefault(t *testing.T) { - config := DefaultStatic - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false (default)", config.Enabled) - } -} - -func TestNewStatic(t *testing.T) { - config := &Static{ - Enabled: true, - Root: "/public", - Index: "index.html", - HTML5: true, - Browse: false, - IgnoreBase: false, - } - - middleware := NewStatic(config) - if middleware == nil { - t.Fatal("NewStatic() returned nil") - } -} - -func TestNewStatic_DefaultConfig(t *testing.T) { - middleware := NewStatic(DefaultStatic) - if middleware == nil { - t.Fatal("NewStatic() with DefaultStatic returned nil") - } -} diff --git a/http/middleware/timeout.go b/http/middleware/timeout.go deleted file mode 100644 index 5f7527c..0000000 --- a/http/middleware/timeout.go +++ /dev/null @@ -1,56 +0,0 @@ -package middleware - -import ( - "time" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/spf13/pflag" -) - -// Timeout holds configuration for request timeout middleware. -type Timeout struct { - // Enabled indicates whether timeout middleware is enabled. - // Optional. Default value true. - Enabled bool - - // ErrorMessage specifies the error message for timeout responses. - // Optional. Default value "Request timeout". - ErrorMessage string - - // Duration specifies the timeout duration. - // Optional. Default value 15 seconds. - Duration time.Duration -} - -// DefaultTimeout provides default Timeout configuration. -var DefaultTimeout = &Timeout{ - Enabled: true, - ErrorMessage: "Request timeout", - Duration: 15 * time.Second, -} - -const ( - TimeoutEnabled = "timeout-enabled" - TimeoutErrorMessage = "timeout-error-message" - TimeoutDuration = "timeout-duration" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (t *Timeout) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Timeout", pflag.ExitOnError) - - fs.BoolVar(&t.Enabled, TimeoutEnabled, t.Enabled, "Enable request timeout middleware") - fs.StringVar(&t.ErrorMessage, TimeoutErrorMessage, t.ErrorMessage, "Error message for timeout responses") - fs.DurationVar(&t.Duration, TimeoutDuration, t.Duration, "Request timeout duration") - - return fs -} - -// NewTimeout creates a new request timeout middleware with the given configuration. -func NewTimeout(config *Timeout) echo.MiddlewareFunc { - return middleware.TimeoutWithConfig(middleware.TimeoutConfig{ - ErrorMessage: config.ErrorMessage, - Timeout: config.Duration, - }) -} diff --git a/http/middleware/timeout_test.go b/http/middleware/timeout_test.go deleted file mode 100644 index 173d914..0000000 --- a/http/middleware/timeout_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package middleware - -import ( - "testing" - "time" -) - -func TestTimeout_FlagSet(t *testing.T) { - config := &Timeout{ - Enabled: false, - ErrorMessage: "Custom timeout message", - Duration: 30 * time.Second, - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - enabledFlag := fs.Lookup(TimeoutEnabled) - if enabledFlag == nil { - t.Errorf("Flag %s not found", TimeoutEnabled) - } else { - if enabledFlag.DefValue != "false" { - t.Errorf("Flag %s default value = %v, want false", TimeoutEnabled, enabledFlag.DefValue) - } - } - - errorMessageFlag := fs.Lookup(TimeoutErrorMessage) - if errorMessageFlag == nil { - t.Errorf("Flag %s not found", TimeoutErrorMessage) - } else { - if errorMessageFlag.DefValue != "Custom timeout message" { - t.Errorf("Flag %s default value = %v, want Custom timeout message", TimeoutErrorMessage, errorMessageFlag.DefValue) - } - } - - durationFlag := fs.Lookup(TimeoutDuration) - if durationFlag == nil { - t.Errorf("Flag %s not found", TimeoutDuration) - } else { - if durationFlag.DefValue != "30s" { - t.Errorf("Flag %s default value = %v, want 30s", TimeoutDuration, durationFlag.DefValue) - } - } -} - -func TestTimeout_FlagSet_Parse(t *testing.T) { - config := &Timeout{ - Enabled: true, - ErrorMessage: "Request timeout", - Duration: 15 * time.Second, - } - - fs := config.FlagSet() - - args := []string{ - "--timeout-enabled=false", - "--timeout-error-message", "Service unavailable", - "--timeout-duration", "45s", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - if config.Enabled { - t.Errorf("Enabled = %v, want false", config.Enabled) - } - if config.ErrorMessage != "Service unavailable" { - t.Errorf("ErrorMessage = %v, want Service unavailable", config.ErrorMessage) - } - if config.Duration != 45*time.Second { - t.Errorf("Duration = %v, want 45s", config.Duration) - } -} - -func TestDefaultTimeout(t *testing.T) { - if DefaultTimeout == nil { - t.Fatal("DefaultTimeout is nil") - } - - if DefaultTimeout.Enabled != true { - t.Errorf("DefaultTimeout.Enabled = %v, want true", DefaultTimeout.Enabled) - } - if DefaultTimeout.ErrorMessage != "Request timeout" { - t.Errorf("DefaultTimeout.ErrorMessage = %v, want Request timeout", DefaultTimeout.ErrorMessage) - } - if DefaultTimeout.Duration != 15*time.Second { - t.Errorf("DefaultTimeout.Duration = %v, want 15s", DefaultTimeout.Duration) - } -} - -func TestTimeout_FlagSet_DefaultValues(t *testing.T) { - config := &Timeout{ - Enabled: false, - ErrorMessage: "Test timeout", - Duration: 5 * time.Minute, - } - - fs := config.FlagSet() - - enabledFlag := fs.Lookup(TimeoutEnabled) - if enabledFlag == nil { - t.Fatal("Enabled flag not found") - } - if enabledFlag.DefValue != "false" { - t.Errorf("Enabled flag default = %v, want false", enabledFlag.DefValue) - } - - errorMessageFlag := fs.Lookup(TimeoutErrorMessage) - if errorMessageFlag == nil { - t.Fatal("ErrorMessage flag not found") - } - if errorMessageFlag.DefValue != "Test timeout" { - t.Errorf("ErrorMessage flag default = %v, want Test timeout", errorMessageFlag.DefValue) - } - - durationFlag := fs.Lookup(TimeoutDuration) - if durationFlag == nil { - t.Fatal("Duration flag not found") - } - if durationFlag.DefValue != "5m0s" { - t.Errorf("Duration flag default = %v, want 5m0s", durationFlag.DefValue) - } -} - -func TestTimeout_FlagSet_EnabledByDefault(t *testing.T) { - config := DefaultTimeout - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - if !config.Enabled { - t.Errorf("Enabled = %v, want true (default)", config.Enabled) - } -} - -func TestNewTimeout(t *testing.T) { - config := &Timeout{ - Enabled: true, - ErrorMessage: "Custom timeout", - Duration: 30 * time.Second, - } - - middleware := NewTimeout(config) - if middleware == nil { - t.Fatal("NewTimeout() returned nil") - } -} - -func TestNewTimeout_DefaultConfig(t *testing.T) { - middleware := NewTimeout(DefaultTimeout) - if middleware == nil { - t.Fatal("NewTimeout() with DefaultTimeout returned nil") - } -} diff --git a/http/server/README.md b/http/server/README.md deleted file mode 100644 index f1517e6..0000000 --- a/http/server/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# server -HTTP/HTTPS server with advanced features built on Echo framework. - -## Installing - -```shell -go get github.com/alexferl/golib/http/server -``` - -## Usage -See [examples/](examples/) for usage. diff --git a/http/server/config.go b/http/server/config.go deleted file mode 100644 index d257bfc..0000000 --- a/http/server/config.go +++ /dev/null @@ -1,319 +0,0 @@ -package server - -import ( - "net/http" - "time" - - "github.com/labstack/echo/v4" - "github.com/spf13/pflag" -) - -// Config holds configuration for the HTTP server. -type Config struct { - // Name specifies the application name. - // Optional. Default value "app". - Name string - - // Version specifies the application version. - // Optional. Default value "1.0.0". - Version string - - // GracefulTimeout specifies the duration for graceful shutdown. - // Optional. Default value 30 seconds. - GracefulTimeout time.Duration - - // HTTP holds HTTP server configuration. - // Optional. Default value with localhost:8080 bind address. - HTTP HTTPConfig - - // TLS holds TLS/HTTPS server configuration. - // Optional. Default value with TLS disabled. - TLS TLSConfig - - // Compress holds compression configuration. - // Optional. Default value with compression disabled. - Compress CompressConfig - - // Redirect holds redirect configuration. - // Optional. Default value with HTTPS redirect disabled. - Redirect RedirectConfig - - // Healthcheck holds healthcheck configuration. - // Optional. Default value with standard endpoints enabled. - Healthcheck HealthcheckConfig - - // Prometheus holds Prometheus metrics configuration. - // Optional. Default value with metrics disabled. - Prometheus PrometheusConfig -} - -// HTTPConfig holds HTTP server configuration. -type HTTPConfig struct { - // BindAddr specifies the HTTP bind address. - // Optional. Default value "localhost:8080". - BindAddr string - - // IdleTimeout specifies the HTTP idle timeout. - // Optional. Default value 60 seconds. - IdleTimeout time.Duration - - // ReadTimeout specifies the HTTP read timeout. - // Optional. Default value 10 seconds. - ReadTimeout time.Duration - - // ReadHeaderTimeout specifies the HTTP read header timeout. - // Optional. Default value 5 seconds. - ReadHeaderTimeout time.Duration - - // WriteTimeout specifies the HTTP write timeout. - // Optional. Default value 10 seconds. - WriteTimeout time.Duration - - // MaxHeaderBytes specifies the maximum header bytes. - // Optional. Default value 1MB. - MaxHeaderBytes int -} - -// TLSConfig holds TLS/HTTPS server configuration. -type TLSConfig struct { - // Enabled indicates whether TLS/HTTPS is enabled. - // Optional. Default value false. - Enabled bool - - // BindAddr specifies the TLS bind address. - // Optional. Default value "localhost:8443". - BindAddr string - - // CertFile specifies the TLS certificate file path. - // Optional. Default value "". - CertFile string - - // KeyFile specifies the TLS key file path. - // Optional. Default value "". - KeyFile string - - // ACME holds ACME/Let's Encrypt configuration. - // Optional. Default value with ACME disabled. - ACME ACMEConfig -} - -// ACMEConfig holds ACME/Let's Encrypt configuration. -type ACMEConfig struct { - // Enabled indicates whether ACME/Let's Encrypt is enabled. - // Optional. Default value false. - Enabled bool - - // Email specifies the ACME email address. - // Optional. Default value "". - Email string - - // HostWhitelist specifies the ACME host whitelist. - // Optional. Default value empty slice. - HostWhitelist []string - - // CachePath specifies the ACME cache path. - // Optional. Default value "./certs". - CachePath string - - // DirectoryURL specifies the ACME directory URL. - // Optional. Default value "". - DirectoryURL string -} - -// CompressConfig holds compression configuration. -type CompressConfig struct { - // Enabled indicates whether compression is enabled. - // Optional. Default value false. - Enabled bool - - // Level specifies the compression level. - // Optional. Default value 6. - Level int - - // MinLength specifies the minimum length for compression. - // Optional. Default value 1024. - MinLength int -} - -// RedirectConfig holds redirect configuration. -type RedirectConfig struct { - // HTTPS indicates whether to redirect HTTP to HTTPS. - // Optional. Default value false. - HTTPS bool - - // Code specifies the redirect status code. - // Optional. Default value 301 (Moved Permanently). - Code int -} - -// HealthcheckConfig holds healthcheck endpoint configuration. -type HealthcheckConfig struct { - // LivenessEndpoint specifies the liveness check endpoint. - // Optional. Default value "/livez". - LivenessEndpoint string - - // LivenessHandler specifies the liveness check handler. - // Optional. Default value returns 200 OK. - LivenessHandler echo.HandlerFunc - - // ReadinessEndpoint specifies the readiness check endpoint. - // Optional. Default value "/readyz". - ReadinessEndpoint string - - // ReadinessHandler specifies the readiness check handler. - // Optional. Default value returns 200 OK. - ReadinessHandler echo.HandlerFunc - - // StartupEndpoint specifies the startup check endpoint. - // Optional. Default value "/startupz". - StartupEndpoint string - - // StartupHandler specifies the startup check handler. - // Optional. Default value returns 200 OK. - StartupHandler echo.HandlerFunc -} - -func defaultHealthcheckHandler(c echo.Context) error { - return c.String(http.StatusOK, "ok") -} - -// PrometheusConfig holds Prometheus metrics configuration. -type PrometheusConfig struct { - // Enabled indicates whether Prometheus metrics are enabled. - // Optional. Default value false. - Enabled bool - - // Path specifies the HTTP path for Prometheus metrics. - // Optional. Default value "/metrics". - Path string -} - -// DefaultConfig provides default server configuration. -var DefaultConfig = &Config{ - Name: "app", - Version: "1.0.0", - GracefulTimeout: 30 * time.Second, - HTTP: HTTPConfig{ - BindAddr: "localhost:8080", - MaxHeaderBytes: 1 << 20, // 1MB - IdleTimeout: 60 * time.Second, - ReadTimeout: 10 * time.Second, - ReadHeaderTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - }, - TLS: TLSConfig{ - Enabled: false, - BindAddr: "localhost:8443", - CertFile: "", - KeyFile: "", - ACME: ACMEConfig{ - Enabled: false, - Email: "", - CachePath: "./certs", - HostWhitelist: []string{}, - DirectoryURL: "", - }, - }, - Compress: CompressConfig{ - Enabled: false, - Level: 6, - MinLength: 1024, - }, - Redirect: RedirectConfig{ - HTTPS: false, - Code: http.StatusMovedPermanently, - }, - Healthcheck: HealthcheckConfig{ - LivenessEndpoint: "/livez", - LivenessHandler: defaultHealthcheckHandler, - ReadinessEndpoint: "/readyz", - ReadinessHandler: defaultHealthcheckHandler, - StartupEndpoint: "/startupz", - StartupHandler: defaultHealthcheckHandler, - }, - Prometheus: PrometheusConfig{ - Enabled: false, - Path: "/metrics", - }, -} - -const ( - ServerName = "server-name" - ServerVersion = "server-version" - ServerGracefulTimeout = "server-graceful-timeout" - ServerHTTPBindAddr = "server-http-bind-addr" - ServerHTTPIdleTimeout = "server-http-idle-timeout" - ServerHTTPReadTimeout = "server-http-read-timeout" - ServerHTTPReadHeaderTimeout = "server-http-read-header-timeout" - ServerHTTPWriteTimeout = "server-http-write-timeout" - ServerHTTPMaxHeaderBytes = "server-http-max-header-bytes" - ServerTLSEnabled = "server-tls-enabled" - ServerTLSBindAddr = "server-tls-bind-addr" - ServerTLSCertFile = "server-tls-cert-file" - ServerTLSKeyFile = "server-tls-key-file" - ServerTLSACMEEnabled = "server-tls-acme-enabled" - ServerTLSACMEEmail = "server-tls-acme-email" - ServerTLSACMEHostWhitelist = "server-tls-acme-host-whitelist" - ServerTLSACMECachePath = "server-tls-acme-cache-path" - ServerTLSACMEDirectoryURL = "server-tls-acme-directory-url" - ServerCompressEnabled = "server-compress-enabled" - ServerCompressLevel = "server-compress-level" - ServerCompressMinLength = "server-compress-min-length" - ServerRedirectHTTPS = "server-redirect-https" - ServerRedirectCode = "server-redirect-code" - ServerHealthcheckLivenessEndpoint = "server-healthcheck-liveness-endpoint" - ServerHealthcheckReadinessEndpoint = "server-healthcheck-readiness-endpoint" - ServerHealthcheckStartupEndpoint = "server-healthcheck-startup-endpoint" - ServerPrometheusEnabled = "server-prometheus-enabled" - ServerPrometheusPath = "server-prometheus-path" -) - -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (c *Config) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Server", pflag.ExitOnError) - - fs.StringVar(&c.Name, ServerName, c.Name, "Application name") - fs.StringVar(&c.Version, ServerVersion, c.Version, "Application version") - fs.DurationVar(&c.GracefulTimeout, ServerGracefulTimeout, c.GracefulTimeout, "Graceful shutdown timeout") - - // HTTP config - fs.StringVar(&c.HTTP.BindAddr, ServerHTTPBindAddr, c.HTTP.BindAddr, "HTTP bind address") - fs.DurationVar(&c.HTTP.IdleTimeout, ServerHTTPIdleTimeout, c.HTTP.IdleTimeout, "HTTP idle timeout") - fs.DurationVar(&c.HTTP.ReadTimeout, ServerHTTPReadTimeout, c.HTTP.ReadTimeout, "HTTP read timeout") - fs.DurationVar(&c.HTTP.ReadHeaderTimeout, ServerHTTPReadHeaderTimeout, c.HTTP.ReadHeaderTimeout, "HTTP read header timeout") - fs.DurationVar(&c.HTTP.WriteTimeout, ServerHTTPWriteTimeout, c.HTTP.WriteTimeout, "HTTP write timeout") - fs.IntVar(&c.HTTP.MaxHeaderBytes, ServerHTTPMaxHeaderBytes, c.HTTP.MaxHeaderBytes, "HTTP max header bytes") - - // TLS config - fs.BoolVar(&c.TLS.Enabled, ServerTLSEnabled, c.TLS.Enabled, "Enable TLS/HTTPS") - fs.StringVar(&c.TLS.BindAddr, ServerTLSBindAddr, c.TLS.BindAddr, "TLS bind address") - fs.StringVar(&c.TLS.CertFile, ServerTLSCertFile, c.TLS.CertFile, "TLS certificate file") - fs.StringVar(&c.TLS.KeyFile, ServerTLSKeyFile, c.TLS.KeyFile, "TLS key file") - - // ACME config - fs.BoolVar(&c.TLS.ACME.Enabled, ServerTLSACMEEnabled, c.TLS.ACME.Enabled, "Enable ACME/Let's Encrypt") - fs.StringVar(&c.TLS.ACME.Email, ServerTLSACMEEmail, c.TLS.ACME.Email, "ACME email address") - fs.StringSliceVar(&c.TLS.ACME.HostWhitelist, ServerTLSACMEHostWhitelist, c.TLS.ACME.HostWhitelist, "ACME host whitelist") - fs.StringVar(&c.TLS.ACME.CachePath, ServerTLSACMECachePath, c.TLS.ACME.CachePath, "ACME cache path") - fs.StringVar(&c.TLS.ACME.DirectoryURL, ServerTLSACMEDirectoryURL, c.TLS.ACME.DirectoryURL, "ACME directory URL") - - // Compression config - fs.BoolVar(&c.Compress.Enabled, ServerCompressEnabled, c.Compress.Enabled, "Enable compression") - fs.IntVar(&c.Compress.Level, ServerCompressLevel, c.Compress.Level, "Compression level") - fs.IntVar(&c.Compress.MinLength, ServerCompressMinLength, c.Compress.MinLength, "Minimum length for compression") - - // Redirect config - fs.BoolVar(&c.Redirect.HTTPS, ServerRedirectHTTPS, c.Redirect.HTTPS, "Redirect HTTP to HTTPS") - fs.IntVar(&c.Redirect.Code, ServerRedirectCode, c.Redirect.Code, "Redirect status code") - - // Healthcheck config - fs.StringVar(&c.Healthcheck.LivenessEndpoint, ServerHealthcheckLivenessEndpoint, c.Healthcheck.LivenessEndpoint, "Liveness check endpoint") - fs.StringVar(&c.Healthcheck.ReadinessEndpoint, ServerHealthcheckReadinessEndpoint, c.Healthcheck.ReadinessEndpoint, "Readiness check endpoint") - fs.StringVar(&c.Healthcheck.StartupEndpoint, ServerHealthcheckStartupEndpoint, c.Healthcheck.StartupEndpoint, "Startup check endpoint") - - // Prometheus config - fs.BoolVar(&c.Prometheus.Enabled, ServerPrometheusEnabled, c.Prometheus.Enabled, "Enable Prometheus metrics") - fs.StringVar(&c.Prometheus.Path, ServerPrometheusPath, c.Prometheus.Path, "Prometheus metrics endpoint path") - - return fs -} diff --git a/http/server/config_test.go b/http/server/config_test.go deleted file mode 100644 index 01dea1a..0000000 --- a/http/server/config_test.go +++ /dev/null @@ -1,507 +0,0 @@ -package server - -import ( - "net/http" - "testing" - "time" -) - -func TestConfig_FlagSet(t *testing.T) { - config := &Config{ - Name: "testapp", - Version: "2.0.0", - GracefulTimeout: 45 * time.Second, - HTTP: HTTPConfig{ - BindAddr: ":9000", - IdleTimeout: 90 * time.Second, - ReadTimeout: 15 * time.Second, - ReadHeaderTimeout: 8 * time.Second, - WriteTimeout: 12 * time.Second, - MaxHeaderBytes: 2 << 20, - }, - TLS: TLSConfig{ - Enabled: true, - BindAddr: ":9443", - CertFile: "/path/to/cert.pem", - KeyFile: "/path/to/key.pem", - ACME: ACMEConfig{ - Enabled: true, - Email: "test@example.com", - HostWhitelist: []string{"example.com", "www.example.com"}, - CachePath: "/tmp/certs", - DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", - }, - }, - Compress: CompressConfig{ - Enabled: true, - Level: 9, - MinLength: 2048, - }, - Redirect: RedirectConfig{ - HTTPS: true, - Code: 302, - }, - Healthcheck: HealthcheckConfig{ - LivenessEndpoint: "/health/live", - ReadinessEndpoint: "/health/ready", - StartupEndpoint: "/health/startup", - }, - Prometheus: PrometheusConfig{ - Enabled: true, - Path: "/custom/metrics", - }, - } - - fs := config.FlagSet() - - if fs == nil { - t.Fatal("FlagSet() returned nil") - } - - // Test basic flags - nameFlag := fs.Lookup(ServerName) - if nameFlag == nil { - t.Errorf("Flag %s not found", ServerName) - } else { - if nameFlag.DefValue != "testapp" { - t.Errorf("Flag %s default value = %v, want testapp", ServerName, nameFlag.DefValue) - } - } - - versionFlag := fs.Lookup(ServerVersion) - if versionFlag == nil { - t.Errorf("Flag %s not found", ServerVersion) - } else { - if versionFlag.DefValue != "2.0.0" { - t.Errorf("Flag %s default value = %v, want 2.0.0", ServerVersion, versionFlag.DefValue) - } - } - - gracefulTimeoutFlag := fs.Lookup(ServerGracefulTimeout) - if gracefulTimeoutFlag == nil { - t.Errorf("Flag %s not found", ServerGracefulTimeout) - } else { - if gracefulTimeoutFlag.DefValue != "45s" { - t.Errorf("Flag %s default value = %v, want 45s", ServerGracefulTimeout, gracefulTimeoutFlag.DefValue) - } - } - - // Test HTTP flags - httpBindAddrFlag := fs.Lookup(ServerHTTPBindAddr) - if httpBindAddrFlag == nil { - t.Errorf("Flag %s not found", ServerHTTPBindAddr) - } else { - if httpBindAddrFlag.DefValue != ":9000" { - t.Errorf("Flag %s default value = %v, want :9000", ServerHTTPBindAddr, httpBindAddrFlag.DefValue) - } - } - - // Test TLS flags - tlsEnabledFlag := fs.Lookup(ServerTLSEnabled) - if tlsEnabledFlag == nil { - t.Errorf("Flag %s not found", ServerTLSEnabled) - } else { - if tlsEnabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", ServerTLSEnabled, tlsEnabledFlag.DefValue) - } - } - - // Test ACME flags - acmeEmailFlag := fs.Lookup(ServerTLSACMEEmail) - if acmeEmailFlag == nil { - t.Errorf("Flag %s not found", ServerTLSACMEEmail) - } else { - if acmeEmailFlag.DefValue != "test@example.com" { - t.Errorf("Flag %s default value = %v, want test@example.com", ServerTLSACMEEmail, acmeEmailFlag.DefValue) - } - } - - // Test Healthcheck flags - livenessFlag := fs.Lookup(ServerHealthcheckLivenessEndpoint) - if livenessFlag == nil { - t.Errorf("Flag %s not found", ServerHealthcheckLivenessEndpoint) - } else { - if livenessFlag.DefValue != "/health/live" { - t.Errorf("Flag %s default value = %v, want /health/live", ServerHealthcheckLivenessEndpoint, livenessFlag.DefValue) - } - } - - readinessFlag := fs.Lookup(ServerHealthcheckReadinessEndpoint) - if readinessFlag == nil { - t.Errorf("Flag %s not found", ServerHealthcheckReadinessEndpoint) - } else { - if readinessFlag.DefValue != "/health/ready" { - t.Errorf("Flag %s default value = %v, want /health/ready", ServerHealthcheckReadinessEndpoint, readinessFlag.DefValue) - } - } - - startupFlag := fs.Lookup(ServerHealthcheckStartupEndpoint) - if startupFlag == nil { - t.Errorf("Flag %s not found", ServerHealthcheckStartupEndpoint) - } else { - if startupFlag.DefValue != "/health/startup" { - t.Errorf("Flag %s default value = %v, want /health/startup", ServerHealthcheckStartupEndpoint, startupFlag.DefValue) - } - } - - // Test Prometheus flags - prometheusEnabledFlag := fs.Lookup(ServerPrometheusEnabled) - if prometheusEnabledFlag == nil { - t.Errorf("Flag %s not found", ServerPrometheusEnabled) - } else { - if prometheusEnabledFlag.DefValue != "true" { - t.Errorf("Flag %s default value = %v, want true", ServerPrometheusEnabled, prometheusEnabledFlag.DefValue) - } - } - - prometheusPathFlag := fs.Lookup(ServerPrometheusPath) - if prometheusPathFlag == nil { - t.Errorf("Flag %s not found", ServerPrometheusPath) - } else { - if prometheusPathFlag.DefValue != "/custom/metrics" { - t.Errorf("Flag %s default value = %v, want /custom/metrics", ServerPrometheusPath, prometheusPathFlag.DefValue) - } - } -} - -func TestConfig_FlagSet_Parse(t *testing.T) { - config := &Config{ - Name: "app", - Version: "1.0.0", - GracefulTimeout: 30 * time.Second, - HTTP: HTTPConfig{ - BindAddr: "localhost:8080", - IdleTimeout: 60 * time.Second, - ReadTimeout: 10 * time.Second, - ReadHeaderTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - MaxHeaderBytes: 1 << 20, - }, - TLS: TLSConfig{ - Enabled: false, - BindAddr: "localhost:8443", - }, - Compress: CompressConfig{ - Enabled: false, - Level: 6, - MinLength: 1024, - }, - Redirect: RedirectConfig{ - HTTPS: false, - Code: 301, - }, - Healthcheck: HealthcheckConfig{ - LivenessEndpoint: "/livez", - ReadinessEndpoint: "/readyz", - StartupEndpoint: "/startupz", - }, - Prometheus: PrometheusConfig{ - Enabled: false, - Path: "/metrics", - }, - } - - fs := config.FlagSet() - - args := []string{ - "--server-name", "myapp", - "--server-version", "3.0.0", - "--server-graceful-timeout", "60s", - "--server-http-bind-addr", ":8081", - "--server-http-idle-timeout", "120s", - "--server-http-read-timeout", "20s", - "--server-http-read-header-timeout", "10s", - "--server-http-write-timeout", "15s", - "--server-http-max-header-bytes", "2097152", - "--server-tls-enabled", - "--server-tls-bind-addr", ":8444", - "--server-tls-cert-file", "/etc/ssl/cert.pem", - "--server-tls-key-file", "/etc/ssl/key.pem", - "--server-tls-acme-enabled", - "--server-tls-acme-email", "admin@example.com", - "--server-tls-acme-host-whitelist", "example.com,api.example.com", - "--server-tls-acme-cache-path", "/var/cache/certs", - "--server-tls-acme-directory-url", "https://acme-v02.api.letsencrypt.org/directory", - "--server-compress-enabled", - "--server-compress-level", "8", - "--server-compress-min-length", "512", - "--server-redirect-https", - "--server-redirect-code", "308", - "--server-healthcheck-liveness-endpoint", "/custom/live", - "--server-healthcheck-readiness-endpoint", "/custom/ready", - "--server-healthcheck-startup-endpoint", "/custom/startup", - "--server-prometheus-enabled", - "--server-prometheus-path", "/custom/metrics", - } - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) - } - - // Verify basic config - if config.Name != "myapp" { - t.Errorf("Name = %v, want myapp", config.Name) - } - if config.Version != "3.0.0" { - t.Errorf("Version = %v, want 3.0.0", config.Version) - } - if config.GracefulTimeout != 60*time.Second { - t.Errorf("GracefulTimeout = %v, want 60s", config.GracefulTimeout) - } - - // Verify HTTP config - if config.HTTP.BindAddr != ":8081" { - t.Errorf("HTTP.BindAddr = %v, want :8081", config.HTTP.BindAddr) - } - if config.HTTP.IdleTimeout != 120*time.Second { - t.Errorf("HTTP.IdleTimeout = %v, want 120s", config.HTTP.IdleTimeout) - } - if config.HTTP.MaxHeaderBytes != 2097152 { - t.Errorf("HTTP.MaxHeaderBytes = %v, want 2097152", config.HTTP.MaxHeaderBytes) - } - - // Verify TLS config - if !config.TLS.Enabled { - t.Errorf("TLS.Enabled = %v, want true", config.TLS.Enabled) - } - if config.TLS.BindAddr != ":8444" { - t.Errorf("TLS.BindAddr = %v, want :8444", config.TLS.BindAddr) - } - if config.TLS.CertFile != "/etc/ssl/cert.pem" { - t.Errorf("TLS.CertFile = %v, want /etc/ssl/cert.pem", config.TLS.CertFile) - } - - // Verify ACME config - if !config.TLS.ACME.Enabled { - t.Errorf("TLS.ACME.Enabled = %v, want true", config.TLS.ACME.Enabled) - } - if config.TLS.ACME.Email != "admin@example.com" { - t.Errorf("TLS.ACME.Email = %v, want admin@example.com", config.TLS.ACME.Email) - } - - // Verify compression config - if !config.Compress.Enabled { - t.Errorf("Compress.Enabled = %v, want true", config.Compress.Enabled) - } - if config.Compress.Level != 8 { - t.Errorf("Compress.Level = %v, want 8", config.Compress.Level) - } - - // Verify redirect config - if !config.Redirect.HTTPS { - t.Errorf("Redirect.HTTPS = %v, want true", config.Redirect.HTTPS) - } - if config.Redirect.Code != 308 { - t.Errorf("Redirect.Code = %v, want 308", config.Redirect.Code) - } - - // Verify healthcheck config - if config.Healthcheck.LivenessEndpoint != "/custom/live" { - t.Errorf("Healthcheck.LivenessEndpoint = %v, want /custom/live", config.Healthcheck.LivenessEndpoint) - } - if config.Healthcheck.ReadinessEndpoint != "/custom/ready" { - t.Errorf("Healthcheck.ReadinessEndpoint = %v, want /custom/ready", config.Healthcheck.ReadinessEndpoint) - } - if config.Healthcheck.StartupEndpoint != "/custom/startup" { - t.Errorf("Healthcheck.StartupEndpoint = %v, want /custom/startup", config.Healthcheck.StartupEndpoint) - } - - // Verify prometheus config - if !config.Prometheus.Enabled { - t.Errorf("Prometheus.Enabled = %v, want true", config.Prometheus.Enabled) - } - if config.Prometheus.Path != "/custom/metrics" { - t.Errorf("Prometheus.Path = %v, want /custom/metrics", config.Prometheus.Path) - } -} - -func TestDefaultConfig(t *testing.T) { - if DefaultConfig == nil { - t.Fatal("DefaultConfig is nil") - } - - if DefaultConfig.Name != "app" { - t.Errorf("DefaultConfig.Name = %v, want app", DefaultConfig.Name) - } - if DefaultConfig.Version != "1.0.0" { - t.Errorf("DefaultConfig.Version = %v, want 1.0.0", DefaultConfig.Version) - } - if DefaultConfig.GracefulTimeout != 30*time.Second { - t.Errorf("DefaultConfig.GracefulTimeout = %v, want 30s", DefaultConfig.GracefulTimeout) - } - - // Test HTTP defaults - if DefaultConfig.HTTP.BindAddr != "localhost:8080" { - t.Errorf("DefaultConfig.HTTP.BindAddr = %v, want localhost:8080", DefaultConfig.HTTP.BindAddr) - } - if DefaultConfig.HTTP.MaxHeaderBytes != 1<<20 { - t.Errorf("DefaultConfig.HTTP.MaxHeaderBytes = %v, want %v", DefaultConfig.HTTP.MaxHeaderBytes, 1<<20) - } - - // Test TLS defaults - if DefaultConfig.TLS.Enabled != false { - t.Errorf("DefaultConfig.TLS.Enabled = %v, want false", DefaultConfig.TLS.Enabled) - } - if DefaultConfig.TLS.BindAddr != "localhost:8443" { - t.Errorf("DefaultConfig.TLS.BindAddr = %v, want localhost:8443", DefaultConfig.TLS.BindAddr) - } - - // Test compression defaults - if DefaultConfig.Compress.Enabled != false { - t.Errorf("DefaultConfig.Compress.Enabled = %v, want false", DefaultConfig.Compress.Enabled) - } - if DefaultConfig.Compress.Level != 6 { - t.Errorf("DefaultConfig.Compress.Level = %v, want 6", DefaultConfig.Compress.Level) - } - - // Test redirect defaults - if DefaultConfig.Redirect.HTTPS != false { - t.Errorf("DefaultConfig.Redirect.HTTPS = %v, want false", DefaultConfig.Redirect.HTTPS) - } - if DefaultConfig.Redirect.Code != http.StatusMovedPermanently { - t.Errorf("DefaultConfig.Redirect.Code = %v, want %v", DefaultConfig.Redirect.Code, http.StatusMovedPermanently) - } - - // Test healthcheck defaults - if DefaultConfig.Healthcheck.LivenessEndpoint != "/livez" { - t.Errorf("DefaultConfig.Healthcheck.LivenessEndpoint = %v, want /livez", DefaultConfig.Healthcheck.LivenessEndpoint) - } - if DefaultConfig.Healthcheck.ReadinessEndpoint != "/readyz" { - t.Errorf("DefaultConfig.Healthcheck.ReadinessEndpoint = %v, want /readyz", DefaultConfig.Healthcheck.ReadinessEndpoint) - } - if DefaultConfig.Healthcheck.StartupEndpoint != "/startupz" { - t.Errorf("DefaultConfig.Healthcheck.StartupEndpoint = %v, want /startupz", DefaultConfig.Healthcheck.StartupEndpoint) - } - if DefaultConfig.Healthcheck.LivenessHandler == nil { - t.Error("DefaultConfig.Healthcheck.LivenessHandler is nil") - } - if DefaultConfig.Healthcheck.ReadinessHandler == nil { - t.Error("DefaultConfig.Healthcheck.ReadinessHandler is nil") - } - if DefaultConfig.Healthcheck.StartupHandler == nil { - t.Error("DefaultConfig.Healthcheck.StartupHandler is nil") - } - - // Test prometheus defaults - if DefaultConfig.Prometheus.Enabled != false { - t.Errorf("DefaultConfig.Prometheus.Enabled = %v, want false", DefaultConfig.Prometheus.Enabled) - } - if DefaultConfig.Prometheus.Path != "/metrics" { - t.Errorf("DefaultConfig.Prometheus.Path = %v, want /metrics", DefaultConfig.Prometheus.Path) - } -} - -func TestConfig_FlagSet_DefaultValues(t *testing.T) { - config := &Config{ - Name: "customapp", - Version: "2.1.0", - GracefulTimeout: 45 * time.Second, - HTTP: HTTPConfig{ - BindAddr: ":9090", - IdleTimeout: 90 * time.Second, - MaxHeaderBytes: 2 << 20, - }, - TLS: TLSConfig{ - Enabled: true, - BindAddr: ":9443", - }, - Healthcheck: HealthcheckConfig{ - LivenessEndpoint: "/custom/live", - ReadinessEndpoint: "/custom/ready", - StartupEndpoint: "/custom/startup", - }, - Prometheus: PrometheusConfig{ - Enabled: true, - Path: "/custom/prometheus", - }, - } - - fs := config.FlagSet() - - nameFlag := fs.Lookup(ServerName) - if nameFlag == nil { - t.Fatal("Name flag not found") - } - if nameFlag.DefValue != "customapp" { - t.Errorf("Name flag default = %v, want customapp", nameFlag.DefValue) - } - - versionFlag := fs.Lookup(ServerVersion) - if versionFlag == nil { - t.Fatal("Version flag not found") - } - if versionFlag.DefValue != "2.1.0" { - t.Errorf("Version flag default = %v, want 2.1.0", versionFlag.DefValue) - } - - httpBindAddrFlag := fs.Lookup(ServerHTTPBindAddr) - if httpBindAddrFlag == nil { - t.Fatal("HTTP BindAddr flag not found") - } - if httpBindAddrFlag.DefValue != ":9090" { - t.Errorf("HTTP BindAddr flag default = %v, want :9090", httpBindAddrFlag.DefValue) - } - - tlsEnabledFlag := fs.Lookup(ServerTLSEnabled) - if tlsEnabledFlag == nil { - t.Fatal("TLS Enabled flag not found") - } - if tlsEnabledFlag.DefValue != "true" { - t.Errorf("TLS Enabled flag default = %v, want true", tlsEnabledFlag.DefValue) - } - - livenessFlag := fs.Lookup(ServerHealthcheckLivenessEndpoint) - if livenessFlag == nil { - t.Fatal("Healthcheck Liveness flag not found") - } - if livenessFlag.DefValue != "/custom/live" { - t.Errorf("Healthcheck Liveness flag default = %v, want /custom/live", livenessFlag.DefValue) - } - - prometheusEnabledFlag := fs.Lookup(ServerPrometheusEnabled) - if prometheusEnabledFlag == nil { - t.Fatal("Prometheus Enabled flag not found") - } - if prometheusEnabledFlag.DefValue != "true" { - t.Errorf("Prometheus Enabled flag default = %v, want true", prometheusEnabledFlag.DefValue) - } - - prometheusPathFlag := fs.Lookup(ServerPrometheusPath) - if prometheusPathFlag == nil { - t.Fatal("Prometheus Path flag not found") - } - if prometheusPathFlag.DefValue != "/custom/prometheus" { - t.Errorf("Prometheus Path flag default = %v, want /custom/prometheus", prometheusPathFlag.DefValue) - } -} - -func TestConfig_FlagSet_EmptyParse(t *testing.T) { - config := DefaultConfig - - fs := config.FlagSet() - - var args []string - - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse empty flags: %v", err) - } - - // Should retain default values - if config.Name != "app" { - t.Errorf("Name = %v, want app (default)", config.Name) - } - if config.TLS.Enabled != false { - t.Errorf("TLS.Enabled = %v, want false (default)", config.TLS.Enabled) - } - if config.Healthcheck.LivenessEndpoint != "/livez" { - t.Errorf("Healthcheck.LivenessEndpoint = %v, want /livez (default)", config.Healthcheck.LivenessEndpoint) - } - if config.Prometheus.Enabled != false { - t.Errorf("Prometheus.Enabled = %v, want false (default)", config.Prometheus.Enabled) - } - if config.Prometheus.Path != "/metrics" { - t.Errorf("Prometheus.Path = %v, want /metrics (default)", config.Prometheus.Path) - } -} diff --git a/http/server/examples/main.go b/http/server/examples/main.go deleted file mode 100644 index 7104e91..0000000 --- a/http/server/examples/main.go +++ /dev/null @@ -1,103 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - "os/signal" - "syscall" - - "github.com/alexferl/golib/config" - "github.com/alexferl/golib/http/middleware" - "github.com/alexferl/golib/http/server" - "github.com/alexferl/golib/logger" - "github.com/labstack/echo/v4" - "github.com/spf13/pflag" -) - -type AppConfig struct { - config.Config - Server *server.Config - Logger *logger.Config - RequestID *middleware.RequestID - RequestLogger *middleware.RequestLogger -} - -func main() { - appConfig := &AppConfig{ - Config: config.Config{AppName: "myapp", EnvName: "local"}, - Server: server.DefaultConfig, - Logger: logger.DefaultConfig, - RequestID: middleware.DefaultRequestID, - RequestLogger: middleware.DefaultLogger, - } - - configLoader := config.NewConfigLoader() - - _, err := configLoader.LoadConfig( - []config.LoadOption{}, - func(fs *pflag.FlagSet) { - fs.AddFlagSet(appConfig.Server.FlagSet()) - fs.AddFlagSet(appConfig.Logger.FlagSet()) - fs.AddFlagSet(appConfig.RequestID.FlagSet()) - fs.AddFlagSet(appConfig.RequestLogger.FlagSet()) - }, - ) - if err != nil { - log.Fatal("failed to load config:", err) - } - - appLogger, err := logger.New(appConfig.Logger) - if err != nil { - log.Fatal("failed to create logger:", err) - } - - appConfig.RequestLogger.Logger = appLogger - - var middlewares []echo.MiddlewareFunc - if appConfig.RequestID.Enabled { - middlewares = append(middlewares, middleware.NewRequestID(appConfig.RequestID)) - } - - if appConfig.RequestLogger.Enabled { - middlewares = append(middlewares, middleware.NewRequestLogger(appConfig.RequestLogger)) - } - - srv := server.New(*appConfig.Server, - server.WithLogger(appLogger), - server.WithMiddleware(middlewares...), - ) - - srv.Echo().GET("/", func(c echo.Context) error { - srv.Logger().Info().Str("endpoint", "/").Msg("Hello endpoint called") - return c.JSON(200, map[string]string{"message": "Hello World"}) - }) - - errCh := srv.Start() - - srv.Logger().Info(). - Str("name", appConfig.Server.Name). - Str("version", appConfig.Server.Version). - Str("addr", appConfig.Server.HTTP.BindAddr). - Bool("tls", appConfig.Server.TLS.Enabled). - Msg("server started") - - quit := make(chan os.Signal, 1) - signal.Notify(quit, os.Interrupt, syscall.SIGTERM) - - select { - case err := <-errCh: - srv.Logger().Fatal().Err(err).Msg("server error") - case <-quit: - srv.Logger().Info().Msg("received shutdown signal") - - ctx, cancel := context.WithTimeout(context.Background(), appConfig.Server.GracefulTimeout) - defer cancel() - - if err := srv.Shutdown(ctx); err != nil { - srv.Logger().Fatal().Err(err).Msg("server forced to shutdown") - } - - srv.Logger().Info().Msg("server exited") - } -} diff --git a/http/server/go.mod b/http/server/go.mod deleted file mode 100644 index 450c079..0000000 --- a/http/server/go.mod +++ /dev/null @@ -1,51 +0,0 @@ -module github.com/alexferl/golib/http/server - -go 1.25 - -require ( - github.com/alexferl/golib/config v0.1.0 - github.com/alexferl/golib/http/middleware v0.1.0 - github.com/alexferl/golib/logger v0.1.0 - github.com/klauspost/compress v1.18.0 - github.com/klauspost/cpuid/v2 v2.3.0 - github.com/labstack/echo-contrib v0.17.4 - github.com/labstack/echo/v4 v4.13.4 - github.com/prometheus/client_golang v1.23.0 - github.com/spf13/pflag v1.0.7 - github.com/ziflex/lecho/v3 v3.8.0 - golang.org/x/crypto v0.41.0 -) - -require ( - github.com/alexferl/echo-secure v0.3.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gorilla/context v1.1.2 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/sessions v1.4.0 // indirect - github.com/labstack/gommon v0.4.2 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.17.0 // indirect - github.com/rs/zerolog v1.34.0 // indirect - github.com/sagikazarmark/locafero v0.10.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect - github.com/spf13/afero v1.14.0 // indirect - github.com/spf13/cast v1.9.2 // indirect - github.com/spf13/viper v1.20.1 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.12.0 // indirect - google.golang.org/protobuf v1.36.7 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/http/server/go.sum b/http/server/go.sum deleted file mode 100644 index 2e44153..0000000 --- a/http/server/go.sum +++ /dev/null @@ -1,119 +0,0 @@ -github.com/alexferl/echo-secure v0.3.0 h1:1teXFQOLLs3Kd3gbK9GmSFDFeA71vkp2C5elsSnPIbY= -github.com/alexferl/echo-secure v0.3.0/go.mod h1:kmbtudSP58dit5bxMcNBxIuKQuJXuvvdEx2ZVfqPDPE= -github.com/alexferl/golib/config v0.1.0 h1:qzicvl0l2Wn4U1bn0VbNNZ3kc3XQ5ucbcrdGn/w7hzM= -github.com/alexferl/golib/config v0.1.0/go.mod h1:5xS4vHuAsoMKxNIFWrrr1fSVDxUZRjtLPnulj+IckaA= -github.com/alexferl/golib/http/middleware v0.1.0 h1:Eo2Ecg+gm128Ne1RoR64OVcs4WO+04xFuPpPegqgMFo= -github.com/alexferl/golib/http/middleware v0.1.0/go.mod h1:8fqvu4lHusiAeTUmN+N4uxlAvGIiUVJfTcSeworspD4= -github.com/alexferl/golib/logger v0.1.0 h1:dy9vWTmo3ihxssPPTXGvfdE22QxluiGxHtbEVDMo+WU= -github.com/alexferl/golib/logger v0.1.0/go.mod h1:BYS2kRGdAWSPDvpIjgsnc28b3ckhVa1y6H3qJo7dCSw= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= -github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= -github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= -github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= -github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= -github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= -github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= -github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= -github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/ziflex/lecho/v3 v3.8.0 h1:de/IyTw5jykpb0GKGk7Di5Y6IeeLpr2PdEBvTvsktD0= -github.com/ziflex/lecho/v3 v3.8.0/go.mod h1:2GzFCQn/W809nLzikFiHkubtU08QRXyE6+VQ9nAhHPE= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http/server/server.go b/http/server/server.go deleted file mode 100644 index fb55c84..0000000 --- a/http/server/server.go +++ /dev/null @@ -1,322 +0,0 @@ -package server - -import ( - "context" - "crypto/tls" - "errors" - "fmt" - "net" - "net/http" - - "github.com/alexferl/golib/logger" - "github.com/klauspost/compress/gzhttp" - "github.com/labstack/echo-contrib/echoprometheus" - "github.com/labstack/echo/v4" - "github.com/ziflex/lecho/v3" - "golang.org/x/crypto/acme" - "golang.org/x/crypto/acme/autocert" -) - -// Server represents an HTTP/HTTPS server with configurable middleware and TLS support. -type Server struct { - config Config - logger *logger.Logger - echo *echo.Echo - httpServer *http.Server - httpsServer *http.Server - errCh chan error - ctx context.Context - cancel context.CancelFunc -} - -// Option is a function that configures a Server. -type Option func(*Server) - -// WithEchoConfig allows custom Echo configuration. -func WithEchoConfig(configFunc func(*echo.Echo)) Option { - return func(s *Server) { - configFunc(s.echo) - } -} - -// WithLogger sets a custom logger for the server. -func WithLogger(logger *logger.Logger) Option { - return func(s *Server) { - s.logger = logger - } -} - -// WithMiddleware adds middleware to the Echo instance. -func WithMiddleware(middlewares ...echo.MiddlewareFunc) Option { - return func(s *Server) { - s.echo.Use(middlewares...) - } -} - -// New creates a new Server instance with the given configuration and options. -func New(config Config, options ...Option) *Server { - ctx, cancel := context.WithCancel(context.Background()) - - e := echo.New() - e.HideBanner = true - e.HidePort = true - - server := &Server{ - config: config, - echo: e, - errCh: make(chan error, 10), - ctx: ctx, - cancel: cancel, - } - - for _, option := range options { - option(server) - } - - if server.logger == nil { - defaultLogger, err := logger.New(logger.DefaultConfig) - if err != nil { - panic(err) - } - server.logger = defaultLogger - } - - server.echo.Logger = lecho.From(server.logger.GetLogger()) - - server.echo.GET(config.Healthcheck.LivenessEndpoint, config.Healthcheck.LivenessHandler) - server.echo.GET(config.Healthcheck.ReadinessEndpoint, config.Healthcheck.ReadinessHandler) - server.echo.GET(config.Healthcheck.StartupEndpoint, config.Healthcheck.StartupHandler) - - if config.Prometheus.Enabled { - server.echo.Use(echoprometheus.NewMiddlewareWithConfig(echoprometheus.MiddlewareConfig{ - Namespace: "", - Subsystem: config.Name, - })) - server.echo.GET(config.Prometheus.Path, echoprometheus.NewHandler()) - } - - return server -} - -// Echo returns the underlying Echo instance. -func (s *Server) Echo() *echo.Echo { - return s.echo -} - -// Logger returns the logger instance used by the server. -func (s *Server) Logger() *logger.Logger { - return s.logger -} - -// Start starts the HTTP or HTTPS server and returns a channel for errors. -func (s *Server) Start() <-chan error { - handler := s.prepareHandler() - - s.httpServer = s.createHTTPServer(s.config.HTTP.BindAddr, handler) - - if !s.config.TLS.Enabled { - s.startHTTPServer() - } else { - s.startHTTPSServer(handler) - } - - return s.errCh -} - -// Shutdown gracefully shuts down the HTTP and HTTPS servers. -func (s *Server) Shutdown(ctx context.Context) error { - // signal all goroutines to stop - s.cancel() - - var errs []error - - if s.httpServer != nil { - if err := s.httpServer.Shutdown(ctx); err != nil { - errs = append(errs, fmt.Errorf("HTTP server shutdown error: %w", err)) - } - } - - if s.httpsServer != nil { - if err := s.httpsServer.Shutdown(ctx); err != nil { - errs = append(errs, fmt.Errorf("HTTPS server shutdown error: %w", err)) - } - } - - close(s.errCh) - - if len(errs) > 0 { - return errors.Join(errs...) - } - - return nil -} - -// prepareHandler prepares the HTTP handler with optional gzip compression. -func (s *Server) prepareHandler() http.Handler { - handler := http.Handler(s.echo) - - if s.config.Compress.Enabled { - gzipHandler, err := gzhttp.NewWrapper( - gzhttp.MinSize(s.config.Compress.MinLength), - gzhttp.CompressionLevel(s.config.Compress.Level), - ) - if err != nil { - s.errCh <- fmt.Errorf("gzip handler error: %w", err) - return handler - } - handler = gzipHandler(s.echo) - } - - return handler -} - -// createHTTPServer creates a new HTTP server with the given address and handler. -func (s *Server) createHTTPServer(addr string, handler http.Handler) *http.Server { - return &http.Server{ - Addr: addr, - Handler: handler, - IdleTimeout: s.config.HTTP.IdleTimeout, - ReadTimeout: s.config.HTTP.ReadTimeout, - ReadHeaderTimeout: s.config.HTTP.ReadHeaderTimeout, - WriteTimeout: s.config.HTTP.WriteTimeout, - MaxHeaderBytes: s.config.HTTP.MaxHeaderBytes, - } -} - -// startHTTPServer starts the HTTP server in a new goroutine. -func (s *Server) startHTTPServer() { - go func() { - if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - s.errCh <- fmt.Errorf("HTTP server error: %w", err) - } - }() -} - -// startHTTPSServer starts the HTTPS server with TLS configuration. -func (s *Server) startHTTPSServer(handler http.Handler) { - s.httpsServer = s.createHTTPServer(s.config.TLS.BindAddr, handler) - - if s.config.TLS.ACME.Enabled { - s.setupACME() - } else { - s.setupManualTLS() - } - - if s.config.Redirect.HTTPS && !s.config.TLS.ACME.Enabled { - s.echo.Pre(s.redirectToHTTPS) - s.startHTTPServer() - } -} - -// setupACME configures the server to use ACME/Let's Encrypt for TLS certificates. -func (s *Server) setupACME() { - acmeClient := &acme.Client{} - autocertManager := autocert.Manager{ - Prompt: autocert.AcceptTOS, - Email: s.config.TLS.ACME.Email, - HostPolicy: autocert.HostWhitelist(s.config.TLS.ACME.HostWhitelist...), - Cache: autocert.DirCache(s.config.TLS.ACME.CachePath), - } - - if s.config.TLS.ACME.DirectoryURL != "" { - acmeClient.DirectoryURL = s.config.TLS.ACME.DirectoryURL - } - - autocertManager.Client = acmeClient - - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - CurvePreferences: defaultCurves, - CipherSuites: getOptimalDefaultCipherSuites(), - GetCertificate: autocertManager.GetCertificate, - } - - s.httpsServer.TLSConfig = tlsConfig - - // HTTP server that listens on port 80 for challenges - _, port, err := net.SplitHostPort(s.config.HTTP.BindAddr) - if err != nil { - s.errCh <- fmt.Errorf("failed to split host/port: %w", err) - return - } - - if port != "80" { - s.errCh <- fmt.Errorf("bind-addr must be set to port 80 for the challenge server") - return - } - - s.httpServer.Handler = autocertManager.HTTPHandler(nil) - go func() { - if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - s.errCh <- fmt.Errorf("HTTP server error: %w", err) - } - }() - - // Start HTTPS server - _, tlsPort, err := net.SplitHostPort(s.config.TLS.BindAddr) - if err != nil { - s.errCh <- fmt.Errorf("failed to split host/port: %w", err) - return - } - - if tlsPort != "443" { - s.errCh <- fmt.Errorf("tls-bind-addr must be set to port 443 for auto TLS") - return - } - - go func() { - if err := s.httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { - s.errCh <- fmt.Errorf("HTTPS server error: %w", err) - } - }() -} - -// setupManualTLS configures the server to use manual TLS certificates. -func (s *Server) setupManualTLS() { - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - CurvePreferences: defaultCurves, - CipherSuites: getOptimalDefaultCipherSuites(), - } - - s.httpsServer.TLSConfig = tlsConfig - - go func() { - if err := s.httpsServer.ListenAndServeTLS( - s.config.TLS.CertFile, - s.config.TLS.KeyFile, - ); err != nil && !errors.Is(err, http.ErrServerClosed) { - s.errCh <- fmt.Errorf("HTTPS server error: %w", err) - } - }() -} - -// redirectToHTTPS redirects HTTP requests to HTTPS. -func (s *Server) redirectToHTTPS(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - req, scheme := c.Request(), c.Scheme() - if scheme != "https" { - host := req.Host - - if h, _, err := net.SplitHostPort(host); err == nil { - host = h - } - - _, tlsPort, err := net.SplitHostPort(s.config.TLS.BindAddr) - if err != nil { - return err - } - - // if TLS port is the default (443), don't include it in the URL - portSuffix := "" - if tlsPort != "443" { - portSuffix = ":" + tlsPort - } - - url := fmt.Sprintf("https://%s%s%s", host, portSuffix, req.RequestURI) - return c.Redirect(s.config.Redirect.Code, url) - } - - return next(c) - } -} diff --git a/http/server/server_test.go b/http/server/server_test.go deleted file mode 100644 index 062d7e1..0000000 --- a/http/server/server_test.go +++ /dev/null @@ -1,413 +0,0 @@ -package server - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/alexferl/golib/logger" - "github.com/labstack/echo/v4" - "github.com/prometheus/client_golang/prometheus" -) - -func TestNew(t *testing.T) { - config := Config{ - Name: "test", - Version: "1.0.0", - HTTP: HTTPConfig{ - BindAddr: ":8080", - }, - } - - server := New(config) - - if server == nil { - t.Fatal("New() returned nil") - } - - if server.config.Name != "test" { - t.Errorf("Expected config name 'test', got '%s'", server.config.Name) - } - - if server.echo == nil { - t.Error("Echo instance is nil") - } - - if server.logger == nil { - t.Error("Logger instance is nil") - } -} - -func TestWithLogger(t *testing.T) { - config := Config{} - customLogger, err := logger.New(logger.DefaultConfig) - if err != nil { - t.Fatalf("Failed to create logger: %v", err) - } - - server := New(config, WithLogger(customLogger)) - - if server.logger != customLogger { - t.Error("Custom logger was not set") - } -} - -func TestWithMiddleware(t *testing.T) { - config := Config{} - middlewareCalled := false - - testMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - middlewareCalled = true - return next(c) - } - } - - server := New(config, WithMiddleware(testMiddleware)) - - e := server.Echo() - e.GET("/test", func(c echo.Context) error { - return c.String(200, "OK") - }) - - req := httptest.NewRequest("GET", "/test", nil) - rec := httptest.NewRecorder() - - e.ServeHTTP(rec, req) - - if !middlewareCalled { - t.Error("Middleware was not called") - } - - if rec.Code != 200 { - t.Errorf("Expected status 200, got %d", rec.Code) - } -} - -func TestWithEchoConfig(t *testing.T) { - config := Config{} - configCalled := false - - server := New(config, WithEchoConfig(func(e *echo.Echo) { - configCalled = true - e.Debug = true - })) - - if !configCalled { - t.Error("Echo config function was not called") - } - - if !server.echo.Debug { - t.Error("Echo debug was not set") - } -} - -func TestEcho(t *testing.T) { - config := Config{} - server := New(config) - - e := server.Echo() - if e == nil { - t.Error("Echo() returned nil") - } - - if e != server.echo { - t.Error("Echo() returned different instance") - } -} - -func TestLogger(t *testing.T) { - config := Config{} - server := New(config) - - l := server.Logger() - if l == nil { - t.Error("Logger() returned nil") - } - - if l != server.logger { - t.Error("Logger() returned different instance") - } -} - -func TestShutdown(t *testing.T) { - config := Config{ - HTTP: HTTPConfig{ - BindAddr: ":0", // Use random port - }, - } - - server := New(config) - - errCh := server.Start() - - // Give server time to start - time.Sleep(100 * time.Millisecond) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err := server.Shutdown(ctx) - if err != nil { - t.Errorf("Shutdown failed: %v", err) - } - - // Check if error channel is closed - select { - case _, ok := <-errCh: - if ok { - t.Error("Error channel should be closed after shutdown") - } - default: - // Channel might be buffered, this is OK - } -} - -func TestPrepareHandler(t *testing.T) { - config := Config{ - Compress: CompressConfig{ - Enabled: false, - }, - } - - server := New(config) - handler := server.prepareHandler() - - if handler == nil { - t.Error("prepareHandler() returned nil") - } -} - -func TestPrepareHandlerWithCompression(t *testing.T) { - config := Config{ - Compress: CompressConfig{ - Enabled: true, - Level: 6, - MinLength: 1024, - }, - } - - server := New(config) - handler := server.prepareHandler() - - if handler == nil { - t.Error("prepareHandler() with compression returned nil") - } -} - -func TestCreateHTTPServer(t *testing.T) { - config := Config{ - HTTP: HTTPConfig{ - BindAddr: ":8080", - IdleTimeout: 60 * time.Second, - ReadTimeout: 10 * time.Second, - ReadHeaderTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - MaxHeaderBytes: 1024, - }, - } - - server := New(config) - httpServer := server.createHTTPServer(":8080", http.DefaultServeMux) - - if httpServer != nil { - if httpServer.Addr != ":8080" { - t.Errorf("Expected address ':8080', got '%s'", httpServer.Addr) - } - - if httpServer.IdleTimeout != 60*time.Second { - t.Errorf("Expected IdleTimeout 60s, got %v", httpServer.IdleTimeout) - } - } else { - t.Error("createHTTPServer() returned nil") - } -} - -func TestHealthcheckEndpoints(t *testing.T) { - config := *DefaultConfig - server := New(config) - - req := httptest.NewRequest("GET", config.Healthcheck.LivenessEndpoint, nil) - rec := httptest.NewRecorder() - c := server.echo.NewContext(req, rec) - - err := config.Healthcheck.LivenessHandler(c) - if err != nil { - t.Errorf("LivenessHandler returned error: %v", err) - } - - if rec.Code != 200 { - t.Errorf("Expected status 200 for liveness, got %d", rec.Code) - } - - req = httptest.NewRequest("GET", config.Healthcheck.ReadinessEndpoint, nil) - rec = httptest.NewRecorder() - c = server.echo.NewContext(req, rec) - - err = config.Healthcheck.ReadinessHandler(c) - if err != nil { - t.Errorf("ReadinessHandler returned error: %v", err) - } - - if rec.Code != 200 { - t.Errorf("Expected status 200 for readiness, got %d", rec.Code) - } - - req = httptest.NewRequest("GET", config.Healthcheck.StartupEndpoint, nil) - rec = httptest.NewRecorder() - c = server.echo.NewContext(req, rec) - - err = config.Healthcheck.StartupHandler(c) - if err != nil { - t.Errorf("StartupHandler returned error: %v", err) - } - - if rec.Code != 200 { - t.Errorf("Expected status 200 for startup, got %d", rec.Code) - } -} - -func TestDefaultHealthcheckHandler(t *testing.T) { - e := echo.New() - req := httptest.NewRequest("GET", "/livez", nil) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - err := defaultHealthcheckHandler(c) - if err != nil { - t.Errorf("defaultHealthcheckHandler returned error: %v", err) - } - - if rec.Code != 200 { - t.Errorf("Expected status 200, got %d", rec.Code) - } - - expectedBody := "ok" - if strings.TrimSpace(rec.Body.String()) != expectedBody { - t.Errorf("Expected body %s, got %s", expectedBody, rec.Body.String()) - } -} - -func TestHealthcheckEndpointsRegistered(t *testing.T) { - config := *DefaultConfig - server := New(config) - - e := server.Echo() - - req := httptest.NewRequest("GET", "/livez", nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != 200 { - t.Errorf("Expected status 200 for liveness endpoint, got %d", rec.Code) - } - - req = httptest.NewRequest("GET", "/readyz", nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != 200 { - t.Errorf("Expected status 200 for readiness endpoint, got %d", rec.Code) - } - - req = httptest.NewRequest("GET", "/startupz", nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != 200 { - t.Errorf("Expected status 200 for startup endpoint, got %d", rec.Code) - } -} - -func resetPrometheusRegistry() { - prometheus.DefaultRegisterer = prometheus.NewRegistry() - prometheus.DefaultGatherer = prometheus.DefaultRegisterer.(prometheus.Gatherer) -} - -func TestPrometheusEndpoint(t *testing.T) { - resetPrometheusRegistry() - defer resetPrometheusRegistry() - - config := Config{ - Name: "testapp", - Prometheus: PrometheusConfig{ - Enabled: true, - Path: "/metrics", - }, - } - - server := New(config) - e := server.Echo() - - req := httptest.NewRequest("GET", "/metrics", nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != 200 { - t.Errorf("Expected status 200 for Prometheus endpoint, got %d", rec.Code) - } - - body := rec.Body.String() - if !strings.Contains(body, "# HELP") || !strings.Contains(body, "# TYPE") { - t.Error("Response does not contain Prometheus metrics format") - } -} - -func TestPrometheusDisabled(t *testing.T) { - resetPrometheusRegistry() - defer resetPrometheusRegistry() - - config := Config{ - Name: "testapp", - Prometheus: PrometheusConfig{ - Enabled: false, - Path: "/metrics", - }, - } - - server := New(config) - e := server.Echo() - - req := httptest.NewRequest("GET", "/metrics", nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != 404 { - t.Errorf("Expected status 404 for disabled Prometheus endpoint, got %d", rec.Code) - } -} - -func TestPrometheusCustomPath(t *testing.T) { - resetPrometheusRegistry() - defer resetPrometheusRegistry() - - config := Config{ - Name: "testapp", - Prometheus: PrometheusConfig{ - Enabled: true, - Path: "/custom/metrics", - }, - } - - server := New(config) - e := server.Echo() - - req := httptest.NewRequest("GET", "/custom/metrics", nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != 200 { - t.Errorf("Expected status 200 for custom Prometheus endpoint, got %d", rec.Code) - } - - req = httptest.NewRequest("GET", "/metrics", nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != 404 { - t.Errorf("Expected status 404 for default metrics path when custom path is used, got %d", rec.Code) - } -} diff --git a/http/server/tls.go b/http/server/tls.go deleted file mode 100644 index 78f3b2f..0000000 --- a/http/server/tls.go +++ /dev/null @@ -1,48 +0,0 @@ -package server - -import ( - "crypto/tls" - - "github.com/klauspost/cpuid/v2" -) - -// defaultCipherSuites is the ordered list of all the cipher -// suites we want to support by default, assuming AES-NI -// (hardware acceleration for AES). -var defaultCipherSuitesWithAESNI = []uint16{ - tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, - tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, -} - -// defaultCipherSuites is the ordered list of all the cipher -// suites we want to support by default, assuming lack of -// AES-NI (NO hardware acceleration for AES). -var defaultCipherSuitesWithoutAESNI = []uint16{ - tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, - tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, - tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, -} - -// getOptimalDefaultCipherSuites returns an appropriate cipher -// suite to use depending on the hardware support for AES. -func getOptimalDefaultCipherSuites() []uint16 { - if cpuid.CPU.Supports(cpuid.AESNI) { - return defaultCipherSuitesWithAESNI - } - return defaultCipherSuitesWithoutAESNI -} - -// defaultCurves is the list of only the curves or key exchange -// mechanisms we want to use by default. The order is irrelevant. -var defaultCurves = []tls.CurveID{ - tls.X25519MLKEM768, - tls.X25519, - tls.CurveP256, -} diff --git a/logger/config.go b/logger/config.go index 97896ab..308c675 100644 --- a/logger/config.go +++ b/logger/config.go @@ -8,18 +8,16 @@ import ( "github.com/spf13/pflag" ) -// Config holds logger configuration options. -type Config struct { - LogLevel string - LogFormat string - LogOutput string +type config struct { + logLevel string + logFormat string + logOutput string } -// DefaultConfig provides sensible default values. -var DefaultConfig = &Config{ - LogLevel: LevelInfo, - LogFormat: FormatText, - LogOutput: OutputStdOut, +var defaultConfig = &config{ + logLevel: LevelInfo, + logFormat: FormatText, + logOutput: OutputStdOut, } const ( @@ -28,42 +26,91 @@ const ( LogOutput = "log-output" ) -// Validate checks if configuration values are valid. -func (c *Config) Validate() error { - logLevel := strings.ToUpper(c.LogLevel) - logFormat := strings.ToLower(c.LogFormat) - logOutput := strings.ToLower(c.LogOutput) +func (c *config) validate() error { + logLevel := strings.ToUpper(c.logLevel) + logFormat := strings.ToLower(c.logFormat) + logOutput := strings.ToLower(c.logOutput) if !slices.Contains(levels, logLevel) { return fmt.Errorf("invalid log level '%s', must be one of: %s", - c.LogLevel, strings.Join(levels, ", ")) + c.logLevel, strings.Join(levels, ", ")) } if !slices.Contains(formats, logFormat) { return fmt.Errorf("invalid log format '%s', must be one of: %s", - c.LogFormat, strings.Join(formats, ", ")) + c.logFormat, strings.Join(formats, ", ")) } if !slices.Contains(outputs, logOutput) { return fmt.Errorf("invalid log output '%s', must be one of: %s", - c.LogOutput, strings.Join(outputs, ", ")) + c.logOutput, strings.Join(outputs, ", ")) } return nil } -// FlagSet returns a pflag.FlagSet for CLI configuration. -func (c *Config) FlagSet() *pflag.FlagSet { - fs := pflag.NewFlagSet("Logger", pflag.ExitOnError) - fs.StringVar(&c.LogLevel, LogLevel, c.LogLevel, +// Option configures a Logger. +type Option func(*config) + +// WithLevel sets the log level on the logger configuration. +func WithLevel(level string) Option { + return func(c *config) { + c.logLevel = level + } +} + +// WithFormat sets the log format on the logger configuration. +func WithFormat(format string) Option { + return func(c *config) { + c.logFormat = format + } +} + +// WithOutput sets the log output on the logger configuration. +func WithOutput(output string) Option { + return func(c *config) { + c.logOutput = output + } +} + +func AddFlags(fs *pflag.FlagSet) { + cfg := *defaultConfig + + fs.String( + LogLevel, + cfg.logLevel, fmt.Sprintf("Log granularity\nValues: %s", strings.Join(levels, ", ")), ) - fs.StringVar(&c.LogFormat, LogFormat, c.LogFormat, + + fs.String( + LogFormat, + cfg.logFormat, fmt.Sprintf("Log format\nValues: %s", strings.Join(formats, ", ")), ) - fs.StringVar(&c.LogOutput, LogOutput, c.LogOutput, + + fs.String( + LogOutput, + cfg.logOutput, fmt.Sprintf("Output destination\nValues: %s", strings.Join(outputs, ", ")), ) +} + +func FromFlags(fs *pflag.FlagSet) (*Logger, error) { + cfg := *defaultConfig - return fs + if f := fs.Lookup(LogLevel); f != nil && f.Value.String() != "" { + cfg.logLevel = f.Value.String() + } + if f := fs.Lookup(LogFormat); f != nil && f.Value.String() != "" { + cfg.logFormat = f.Value.String() + } + if f := fs.Lookup(LogOutput); f != nil && f.Value.String() != "" { + cfg.logOutput = f.Value.String() + } + + return New( + WithLevel(cfg.logLevel), + WithFormat(cfg.logFormat), + WithOutput(cfg.logOutput), + ) } diff --git a/logger/config_test.go b/logger/config_test.go index 3518dba..d1d5b01 100644 --- a/logger/config_test.go +++ b/logger/config_test.go @@ -3,69 +3,71 @@ package logger import ( "strings" "testing" + + "github.com/spf13/pflag" ) -func TestConfig_Validate(t *testing.T) { +func Test_config_validate(t *testing.T) { tests := []struct { name string - config *Config + cfg *config wantErr bool errMsg string }{ { name: "valid config", - config: &Config{ - LogLevel: "INFO", - LogFormat: "json", - LogOutput: "stdout", + cfg: &config{ + logLevel: "INFO", + logFormat: "json", + logOutput: "stdout", }, wantErr: false, }, { name: "valid config case insensitive", - config: &Config{ - LogLevel: "debug", - LogFormat: "TEXT", - LogOutput: "STDERR", + cfg: &config{ + logLevel: "debug", + logFormat: "TEXT", + logOutput: "STDERR", }, wantErr: false, }, { name: "invalid log level", - config: &Config{ - LogLevel: "INVALID", - LogFormat: "json", - LogOutput: "stdout", + cfg: &config{ + logLevel: "INVALID", + logFormat: "json", + logOutput: "stdout", }, wantErr: true, errMsg: "invalid log level 'INVALID'", }, { name: "invalid log format", - config: &Config{ - LogLevel: "INFO", - LogFormat: "xml", - LogOutput: "stdout", + cfg: &config{ + logLevel: "INFO", + logFormat: "xml", + logOutput: "stdout", }, wantErr: true, errMsg: "invalid log format 'xml'", }, { name: "invalid log output", - config: &Config{ - LogLevel: "INFO", - LogFormat: "json", - LogOutput: "file", + cfg: &config{ + logLevel: "INFO", + logFormat: "json", + logOutput: "file", }, wantErr: true, errMsg: "invalid log output 'file'", }, { name: "empty values", - config: &Config{ - LogLevel: "", - LogFormat: "", - LogOutput: "", + cfg: &config{ + logLevel: "", + logFormat: "", + logOutput: "", }, wantErr: true, errMsg: "invalid log level ''", @@ -74,128 +76,110 @@ func TestConfig_Validate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := tt.config.Validate() + err := tt.cfg.validate() if tt.wantErr { if err == nil { - t.Errorf("Config.Validate() expected error but got nil") + t.Errorf("config.validate() expected error but got nil") return } if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("Config.Validate() error = %v, want error containing %v", err, tt.errMsg) + t.Errorf("config.validate() error = %v, want error containing %v", err, tt.errMsg) } } else { if err != nil { - t.Errorf("Config.Validate() unexpected error = %v", err) + t.Errorf("config.validate() unexpected error = %v", err) } } }) } } -func TestConfig_FlagSet(t *testing.T) { - config := &Config{ - LogLevel: "DEBUG", - LogFormat: "text", - LogOutput: "stderr", +func Test_defaultConfig(t *testing.T) { + if defaultConfig == nil { + t.Fatal("defaultConfig is nil") } - fs := config.FlagSet() - - // Test that flagset is created - if fs == nil { - t.Fatal("FlagSet() returned nil") + if defaultConfig.logLevel != LevelInfo { + t.Errorf("defaultConfig.logLevel = %v, want %v", defaultConfig.logLevel, LevelInfo) } - - // Test that flags are defined - logLevelFlag := fs.Lookup(LogLevel) - if logLevelFlag == nil { - t.Errorf("Flag %s not found", LogLevel) - } else { - if logLevelFlag.DefValue != "DEBUG" { - t.Errorf("Flag %s default value = %v, want DEBUG", LogLevel, logLevelFlag.DefValue) - } + if defaultConfig.logFormat != FormatText { + t.Errorf("defaultConfig.logFormat = %v, want %v", defaultConfig.logFormat, FormatText) } - - logFormatFlag := fs.Lookup(LogFormat) - if logFormatFlag == nil { - t.Errorf("Flag %s not found", LogFormat) - } else { - if logFormatFlag.DefValue != "text" { - t.Errorf("Flag %s default value = %v, want text", LogFormat, logFormatFlag.DefValue) - } + if defaultConfig.logOutput != OutputStdOut { + t.Errorf("defaultConfig.logOutput = %v, want %v", defaultConfig.logOutput, OutputStdOut) } - logOutputFlag := fs.Lookup(LogOutput) - if logOutputFlag == nil { - t.Errorf("Flag %s not found", LogOutput) - } else { - if logOutputFlag.DefValue != "stderr" { - t.Errorf("Flag %s default value = %v, want stderr", LogOutput, logOutputFlag.DefValue) - } + if err := defaultConfig.validate(); err != nil { + t.Errorf("defaultConfig.validate() error = %v", err) } } -func TestConfig_FlagSet_Parse(t *testing.T) { - config := &Config{ - LogLevel: "INFO", - LogFormat: "json", - LogOutput: "stdout", +func TestOptions_apply(t *testing.T) { + cfg := &config{ + logLevel: LevelInfo, + logFormat: FormatText, + logOutput: OutputStdOut, } - fs := config.FlagSet() - - // Test parsing flags - args := []string{ - "--log-level", "ERROR", - "--log-format", "text", - "--log-output", "stderr", + opts := []Option{ + WithLevel(LevelDebug), + WithFormat(FormatJSON), + WithOutput(OutputStdErr), } - err := fs.Parse(args) - if err != nil { - t.Fatalf("Failed to parse flags: %v", err) + for _, opt := range opts { + opt(cfg) } - // Check that config values were updated - if config.LogLevel != "ERROR" { - t.Errorf("LogLevel = %v, want ERROR", config.LogLevel) + if cfg.logLevel != LevelDebug { + t.Errorf("logLevel = %v, want %v", cfg.logLevel, LevelDebug) } - if config.LogFormat != "text" { - t.Errorf("LogFormat = %v, want text", config.LogFormat) + if cfg.logFormat != FormatJSON { + t.Errorf("logFormat = %v, want %v", cfg.logFormat, FormatJSON) } - if config.LogOutput != "stderr" { - t.Errorf("LogOutput = %v, want stderr", config.LogOutput) + if cfg.logOutput != OutputStdErr { + t.Errorf("logOutput = %v, want %v", cfg.logOutput, OutputStdErr) } } -func TestDefaultConfig(t *testing.T) { - if DefaultConfig == nil { - t.Fatal("DefaultConfig is nil") - } +func TestAddFlags_DefinitionsAndDefaults(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + AddFlags(fs) - // Test default values - if DefaultConfig.LogLevel != LevelInfo { - t.Errorf("DefaultConfig.LogLevel = %v, want %v", DefaultConfig.LogLevel, LevelInfo) + // log-level + logLevelFlag := fs.Lookup(LogLevel) + if logLevelFlag == nil { + t.Fatalf("Flag %s not found", LogLevel) } - if DefaultConfig.LogFormat != FormatText { - t.Errorf("DefaultConfig.LogFormat = %v, want %v", DefaultConfig.LogFormat, FormatText) + if logLevelFlag.DefValue != defaultConfig.logLevel { + t.Errorf("Flag %s default value = %v, want %v", LogLevel, logLevelFlag.DefValue, defaultConfig.logLevel) + } + + // log-format + logFormatFlag := fs.Lookup(LogFormat) + if logFormatFlag == nil { + t.Fatalf("Flag %s not found", LogFormat) } - if DefaultConfig.LogOutput != OutputStdOut { - t.Errorf("DefaultConfig.LogOutput = %v, want %v", DefaultConfig.LogOutput, OutputStdOut) + if logFormatFlag.DefValue != defaultConfig.logFormat { + t.Errorf("Flag %s default value = %v, want %v", LogFormat, logFormatFlag.DefValue, defaultConfig.logFormat) } - // Test that default config is valid - err := DefaultConfig.Validate() - if err != nil { - t.Errorf("DefaultConfig.Validate() error = %v", err) + // log-output + logOutputFlag := fs.Lookup(LogOutput) + if logOutputFlag == nil { + t.Fatalf("Flag %s not found", LogOutput) + } + if logOutputFlag.DefValue != defaultConfig.logOutput { + t.Errorf("Flag %s default value = %v, want %v", LogOutput, logOutputFlag.DefValue, defaultConfig.logOutput) } } -func TestConfig_FlagSet_Usage(t *testing.T) { - config := &Config{} - fs := config.FlagSet() +func TestAddFlags_Usage(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + AddFlags(fs) - // Test that usage strings contain expected values logLevelFlag := fs.Lookup(LogLevel) if logLevelFlag != nil { usage := logLevelFlag.Usage @@ -226,3 +210,77 @@ func TestConfig_FlagSet_Usage(t *testing.T) { } } } + +func TestFromFlags_ValidValues(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + AddFlags(fs) + + args := []string{ + "--" + LogLevel, "ERROR", + "--" + LogFormat, "json", + "--" + LogOutput, "stderr", + } + if err := fs.Parse(args); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + l, err := FromFlags(fs) + if err != nil { + t.Fatalf("FromFlags() unexpected error: %v", err) + } + if l == nil { + t.Fatal("FromFlags() returned nil logger") + } + + // sanity check: logger uses the settings (no panic / nil event) + if l.Error() == nil { + t.Errorf("Logger.Error() returned nil event") + } +} + +func TestFromFlags_InvalidValues(t *testing.T) { + tests := []struct { + name string + args []string + errMsg string + }{ + { + name: "invalid level", + args: []string{"--" + LogLevel, "INVALID"}, + errMsg: "invalid log level 'INVALID'", + }, + { + name: "invalid format", + args: []string{ + "--" + LogFormat, "xml", + }, + errMsg: "invalid log format 'xml'", + }, + { + name: "invalid output", + args: []string{ + "--" + LogOutput, "file", + }, + errMsg: "invalid log output 'file'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + AddFlags(fs) + + if err := fs.Parse(tt.args); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + _, err := FromFlags(fs) + if err == nil { + t.Fatalf("FromFlags() expected error but got nil") + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("FromFlags() error = %v, want error containing %v", err, tt.errMsg) + } + }) + } +} diff --git a/logger/examples/main.go b/logger/examples/main.go index b09b602..3fd806b 100644 --- a/logger/examples/main.go +++ b/logger/examples/main.go @@ -4,17 +4,29 @@ import ( "log" "github.com/alexferl/golib/logger" + "github.com/spf13/pflag" ) func main() { - // Example 1: JSON format to stderr with DEBUG level - config1 := &logger.Config{ - LogLevel: "DEBUG", - LogFormat: "json", - LogOutput: "stderr", + // Add logger CLI flags: --log-level, --log-format, --log-output + logger.AddFlags(pflag.CommandLine) + + // Parse all flags (including logger flags) + pflag.Parse() + + // Create a logger based on CLI flags (with validation) + cliLogger, err := logger.FromFlags(pflag.CommandLine) + if err != nil { + log.Fatal(err) } + cliLogger.Info().Msg("Logger configured from CLI flags") - jsonLogger, err := logger.New(config1) + // Example 1: JSON format to stderr with DEBUG level + jsonLogger, err := logger.New( + logger.WithLevel(logger.LevelDebug), + logger.WithFormat(logger.FormatJSON), + logger.WithOutput(logger.OutputStdErr), + ) if err != nil { log.Fatal(err) } @@ -22,21 +34,19 @@ func main() { jsonLogger.Debug().Str("format", "json").Msg("This is JSON formatted") // Example 2: Text format to stdout with INFO level - config2 := &logger.Config{ - LogLevel: "INFO", - LogFormat: "text", - LogOutput: "stdout", - } - - textLogger, err := logger.New(config2) + textLogger, err := logger.New( + logger.WithLevel(logger.LevelInfo), + logger.WithFormat(logger.FormatText), + logger.WithOutput(logger.OutputStdOut), + ) if err != nil { log.Fatal(err) } textLogger.Info().Str("format", "text").Msg("This is human-readable text") - // Example 3: Using default config - defaultLogger, err := logger.New(nil) + // Example 3: Using default config (INFO, text, stdout) + defaultLogger, err := logger.New() if err != nil { log.Fatal(err) } diff --git a/logger/go.mod b/logger/go.mod index ee6eca8..1dbb3f8 100644 --- a/logger/go.mod +++ b/logger/go.mod @@ -4,11 +4,11 @@ go 1.25 require ( github.com/rs/zerolog v1.34.0 - github.com/spf13/pflag v1.0.7 + github.com/spf13/pflag v1.0.10 ) require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.39.0 // indirect ) diff --git a/logger/go.sum b/logger/go.sum index 7d8a756..181cee0 100644 --- a/logger/go.sum +++ b/logger/go.sum @@ -11,10 +11,10 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/logger/logger.go b/logger/logger.go index 649e61b..616ef04 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -38,46 +38,39 @@ var outputs = []string{OutputStdOut, OutputStdErr} // Logger wraps zerolog.Logger with configuration. type Logger struct { logger zerolog.Logger - config *Config + config *config } -// New creates a new Logger instance with the given config. -// Uses DefaultConfig if config is nil. -func New(config *Config) (*Logger, error) { - if config == nil { - config = DefaultConfig - } +// New creates a new Logger instance using functional options. +// It starts from DefaultConfig and applies any provided options. +func New(opts ...Option) (*Logger, error) { + cfg := *defaultConfig + conf := &cfg - if config.LogLevel == "" { - config.LogLevel = DefaultConfig.LogLevel - } - if config.LogOutput == "" { - config.LogOutput = DefaultConfig.LogOutput - } - if config.LogFormat == "" { - config.LogFormat = DefaultConfig.LogFormat + for _, opt := range opts { + opt(conf) } - if err := config.Validate(); err != nil { + if err := conf.validate(); err != nil { return nil, err } - logger, err := createZerologLogger(config) + zl, err := createZerologLogger(conf) if err != nil { return nil, err } return &Logger{ - logger: logger, - config: config, + logger: zl, + config: conf, }, nil } // createZerologLogger creates and configures a zerolog.Logger. -func createZerologLogger(config *Config) (zerolog.Logger, error) { - logOutput := strings.ToLower(config.LogOutput) - logFormat := strings.ToLower(config.LogFormat) - logLevel := strings.ToUpper(config.LogLevel) +func createZerologLogger(config *config) (zerolog.Logger, error) { + logOutput := strings.ToLower(config.logOutput) + logFormat := strings.ToLower(config.logFormat) + logLevel := strings.ToUpper(config.logLevel) var output io.Writer switch logOutput { @@ -142,11 +135,6 @@ func (l *Logger) GetLogger() zerolog.Logger { return l.logger } -// GetConfig returns the logger configuration. -func (l *Logger) GetConfig() *Config { - return l.config -} - // Panic creates a panic level log event. func (l *Logger) Panic() *zerolog.Event { return l.logger.Panic() diff --git a/logger/logger_test.go b/logger/logger_test.go index ec404a2..77c6812 100644 --- a/logger/logger_test.go +++ b/logger/logger_test.go @@ -12,59 +12,50 @@ import ( func TestNew(t *testing.T) { tests := []struct { name string - config *Config + opts []Option wantErr bool errMsg string }{ { - name: "nil config uses default", - config: nil, + name: "no options uses default", + opts: nil, wantErr: false, }, { - name: "valid config", - config: &Config{ - LogLevel: "INFO", - LogFormat: "json", - LogOutput: "stdout", - }, - wantErr: false, - }, - { - name: "empty fields use defaults", - config: &Config{ - LogLevel: "", - LogFormat: "", - LogOutput: "", + name: "valid options", + opts: []Option{ + WithLevel("INFO"), + WithFormat("json"), + WithOutput("stdout"), }, wantErr: false, }, { name: "invalid log level", - config: &Config{ - LogLevel: "INVALID", - LogFormat: "json", - LogOutput: "stdout", + opts: []Option{ + WithLevel("INVALID"), + WithFormat("json"), + WithOutput("stdout"), }, wantErr: true, errMsg: "invalid log level", }, { name: "invalid log format", - config: &Config{ - LogLevel: "INFO", - LogFormat: "xml", - LogOutput: "stdout", + opts: []Option{ + WithLevel("INFO"), + WithFormat("xml"), + WithOutput("stdout"), }, wantErr: true, errMsg: "invalid log format", }, { name: "invalid log output", - config: &Config{ - LogLevel: "INFO", - LogFormat: "json", - LogOutput: "file", + opts: []Option{ + WithLevel("INFO"), + WithFormat("json"), + WithOutput("file"), }, wantErr: true, errMsg: "invalid log output", @@ -73,7 +64,7 @@ func TestNew(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - logger, err := New(tt.config) + l, err := New(tt.opts...) if tt.wantErr { if err == nil { t.Errorf("New() expected error but got nil") @@ -87,7 +78,7 @@ func TestNew(t *testing.T) { t.Errorf("New() unexpected error = %v", err) return } - if logger == nil { + if l == nil { t.Errorf("New() returned nil logger") } } @@ -98,42 +89,42 @@ func TestNew(t *testing.T) { func TestCreateZerologLogger(t *testing.T) { tests := []struct { name string - config *Config + cfg *config wantErr bool }{ { name: "text format stdout", - config: &Config{ - LogLevel: "INFO", - LogFormat: "text", - LogOutput: "stdout", + cfg: &config{ + logLevel: "INFO", + logFormat: "text", + logOutput: "stdout", }, wantErr: false, }, { name: "json format stderr", - config: &Config{ - LogLevel: "DEBUG", - LogFormat: "json", - LogOutput: "stderr", + cfg: &config{ + logLevel: "DEBUG", + logFormat: "json", + logOutput: "stderr", }, wantErr: false, }, { name: "unknown output", - config: &Config{ - LogLevel: "INFO", - LogFormat: "json", - LogOutput: "file", + cfg: &config{ + logLevel: "INFO", + logFormat: "json", + logOutput: "file", }, wantErr: true, }, { name: "unknown format", - config: &Config{ - LogLevel: "INFO", - LogFormat: "xml", - LogOutput: "stdout", + cfg: &config{ + logLevel: "INFO", + logFormat: "xml", + logOutput: "stdout", }, wantErr: true, }, @@ -141,7 +132,7 @@ func TestCreateZerologLogger(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - logger, err := createZerologLogger(tt.config) + logger, err := createZerologLogger(tt.cfg) if tt.wantErr { if err == nil { t.Errorf("createZerologLogger() expected error but got nil") @@ -197,27 +188,18 @@ func TestParseLogLevel(t *testing.T) { } } -func TestLogger_GetMethods(t *testing.T) { - config := &Config{ - LogLevel: "INFO", - LogFormat: "json", - LogOutput: "stdout", - } - - logger, err := New(config) +func TestLogger_GetLogger(t *testing.T) { + l, err := New( + WithLevel("INFO"), + WithFormat("json"), + WithOutput("stdout"), + ) if err != nil { t.Fatalf("Failed to create logger: %v", err) } - // Test GetConfig - gotConfig := logger.GetConfig() - if gotConfig != config { - t.Errorf("GetConfig() = %v, want %v", gotConfig, config) - } - - // Test GetLogger - verify it can be used - zerologLogger := logger.GetLogger() - event := zerologLogger.Info() + zl := l.GetLogger() + event := zl.Info() if event == nil { t.Errorf("GetLogger() returned non-functional logger") } @@ -230,10 +212,9 @@ func TestLogger_LogMethods(t *testing.T) { zerologLogger := zerolog.New(&buf).With().Timestamp().Logger() logger := &Logger{ logger: zerologLogger, - config: DefaultConfig, + config: defaultConfig, } - // Test each log method tests := []struct { name string logFn func() *zerolog.Event @@ -252,7 +233,6 @@ func TestLogger_LogMethods(t *testing.T) { t.Run(tt.name, func(t *testing.T) { buf.Reset() - // Don't actually send the message for panic/fatal as they would exit if tt.level == "panic" || tt.level == "fatal" { event := tt.logFn() if event == nil { @@ -290,7 +270,7 @@ func TestLogger_WithLevel(t *testing.T) { zerologLogger := zerolog.New(&buf).With().Timestamp().Logger() logger := &Logger{ logger: zerologLogger, - config: DefaultConfig, + config: defaultConfig, } logger.WithLevel(zerolog.WarnLevel).Msg("test message") @@ -316,7 +296,7 @@ func TestLogger_With(t *testing.T) { zerologLogger := zerolog.New(&buf).With().Timestamp().Logger() logger := &Logger{ logger: zerologLogger, - config: DefaultConfig, + config: defaultConfig, } contextLogger := logger.With().Str("component", "test").Logger() @@ -343,7 +323,7 @@ func TestLogger_Log(t *testing.T) { zerologLogger := zerolog.New(&buf).With().Timestamp().Logger() logger := &Logger{ logger: zerologLogger, - config: DefaultConfig, + config: defaultConfig, } event := logger.Log()