diff --git a/go.mod b/go.mod index 104d979..6029104 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module notashelf.dev/watchdog go 1.25.5 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9725105 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,104 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Configuration structure +type Config struct { + Site SiteConfig `yaml:"site"` + Limits LimitsConfig `yaml:"limits"` + Server ServerConfig `yaml:"server"` +} + +// Site-specific settings +type SiteConfig struct { + Domain string `yaml:"domain"` + SaltRotation string `yaml:"salt_rotation"` + Collect CollectConfig `yaml:"collect"` + CustomEvents []string `yaml:"custom_events"` + Path PathConfig `yaml:"path"` +} + +// Which dimensions to collect +type CollectConfig struct { + Pageviews bool `yaml:"pageviews"` + Country bool `yaml:"country"` + Device bool `yaml:"device"` + Referrer string `yaml:"referrer"` +} + +// Path normalization options +type PathConfig struct { + StripQuery bool `yaml:"strip_query"` + StripFragment bool `yaml:"strip_fragment"` + CollapseNumericSegments bool `yaml:"collapse_numeric_segments"` + MaxSegments int `yaml:"max_segments"` +} + +// Cardinality limits +type LimitsConfig struct { + MaxPaths int `yaml:"max_paths"` + MaxEventsPerMinute int `yaml:"max_events_per_minute"` +} + +// Server endpoints +type ServerConfig struct { + ListenAddr string `yaml:"listen_addr"` + MetricsPath string `yaml:"metrics_path"` + IngestionPath string `yaml:"ingestion_path"` +} + +// YAML configuration file +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + + return &cfg, nil +} + +// Check required fields and sets defaults +// FIXME: in the future we need to validate in the config parser +func (c *Config) Validate() error { + // Validate site domain is required + if c.Site.Domain == "" { + return fmt.Errorf("site.domain is required") + } + + // Validate salt_rotation if provided + if c.Site.SaltRotation != "" && c.Site.SaltRotation != "daily" && c.Site.SaltRotation != "hourly" { + return fmt.Errorf("site.salt_rotation must be 'daily' or 'hourly'") + } + + // Validate max_paths is positive + if c.Limits.MaxPaths <= 0 { + return fmt.Errorf("limits.max_paths must be greater than 0") + } + + // Set server defaults if not provided + if c.Server.ListenAddr == "" { + c.Server.ListenAddr = ":8080" + } + if c.Server.MetricsPath == "" { + c.Server.MetricsPath = "/metrics" + } + if c.Server.IngestionPath == "" { + c.Server.IngestionPath = "/api/event" + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..43651f9 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "testing" +) + +func TestLoadConfig_ValidFile(t *testing.T) { + cfg, err := Load("../../testdata/config.valid.yaml") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if cfg.Site.Domain != "example.com" { + t.Errorf("expected domain 'example.com', got '%s'", cfg.Site.Domain) + } + + if cfg.Site.SaltRotation != "daily" { + t.Errorf("expected salt_rotation 'daily', got '%s'", cfg.Site.SaltRotation) + } +} + +func TestLoadConfig_MissingFile(t *testing.T) { + _, err := Load("nonexistent.yaml") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestValidate_MaxPathsRequired(t *testing.T) { + cfg := &Config{ + Site: SiteConfig{ + Domain: "example.com", + SaltRotation: "daily", + }, + Limits: LimitsConfig{ + MaxPaths: 0, // invalid + }, + } + + err := cfg.Validate() + if err == nil { + t.Fatal("expected validation error for max_paths=0, got nil") + } +} diff --git a/testdata/config.invalid.yaml b/testdata/config.invalid.yaml new file mode 100644 index 0000000..066753f --- /dev/null +++ b/testdata/config.invalid.yaml @@ -0,0 +1,6 @@ +site: + domain: "" + salt_rotation: daily + +limits: + max_paths: 0 diff --git a/testdata/config.valid.yaml b/testdata/config.valid.yaml new file mode 100644 index 0000000..c723c6a --- /dev/null +++ b/testdata/config.valid.yaml @@ -0,0 +1,25 @@ +site: + domain: example.com + salt_rotation: daily + collect: + pageviews: true + country: true + device: true + referrer: classify + custom_events: + - signup + - purchase + path: + strip_query: true + strip_fragment: true + collapse_numeric_segments: true + max_segments: 5 + +limits: + max_paths: 1000 + max_events_per_minute: 10000 + +server: + listen_addr: :8080 + metrics_path: /metrics + ingestion_path: /api/event