config: data structures; basic tests

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia7d6f19a46ec8a4987ea429ec6502f676a6a6964
This commit is contained in:
raf 2026-03-01 00:47:13 +03:00
commit 4c84393286
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
6 changed files with 185 additions and 0 deletions

104
internal/config/config.go Normal file
View file

@ -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
}

View file

@ -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")
}
}