package config import ( "bytes" "os" "strings" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" "gopkg.in/yaml.v3" ) type service struct { logger mlogger.Logger } // New creates a configuration loader. func New(logger mlogger.Logger) Loader { if logger == nil { logger = zap.NewNop() } return &service{logger: logger.Named("config")} } func (s *service) Load(path string) (*Config, error) { if strings.TrimSpace(path) == "" { return nil, merrors.InvalidArgument("config path is required", "path") } data, err := os.ReadFile(path) if err != nil { s.logger.Error("Failed to read config file", zap.String("path", path), zap.Error(err)) return nil, merrors.InternalWrap(err, "failed to read callbacks config") } cfg := &Config{} decoder := yaml.NewDecoder(bytes.NewReader(data)) decoder.KnownFields(true) if err := decoder.Decode(cfg); err != nil { s.logger.Error("Failed to parse config yaml", zap.String("path", path), zap.Error(err)) return nil, merrors.InternalWrap(err, "failed to parse callbacks config") } s.applyDefaults(cfg) if err := s.validate(cfg); err != nil { return nil, err } return cfg, nil } func (s *service) applyDefaults(cfg *Config) { if cfg.Runtime == nil { cfg.Runtime = &RuntimeConfig{ShutdownTimeoutSeconds: defaultShutdownTimeoutSeconds} } if cfg.Metrics == nil { cfg.Metrics = &MetricsConfig{Address: defaultMetricsAddress} } else if strings.TrimSpace(cfg.Metrics.Address) == "" { cfg.Metrics.Address = defaultMetricsAddress } if cfg.Delivery.WorkerConcurrency <= 0 { cfg.Delivery.WorkerConcurrency = defaultWorkerConcurrency } if cfg.Delivery.WorkerPollMS <= 0 { cfg.Delivery.WorkerPollMS = defaultWorkerPollIntervalMS } if cfg.Delivery.LockTTLSeconds <= 0 { cfg.Delivery.LockTTLSeconds = defaultLockTTLSeconds } if cfg.Delivery.RequestTimeoutMS <= 0 { cfg.Delivery.RequestTimeoutMS = defaultRequestTimeoutMS } if cfg.Delivery.MaxAttempts <= 0 { cfg.Delivery.MaxAttempts = defaultMaxAttempts } if cfg.Delivery.MinDelayMS <= 0 { cfg.Delivery.MinDelayMS = defaultMinDelayMS } if cfg.Delivery.MaxDelayMS <= 0 { cfg.Delivery.MaxDelayMS = defaultMaxDelayMS } if cfg.Delivery.JitterRatio <= 0 { cfg.Delivery.JitterRatio = defaultJitterRatio } if cfg.Delivery.JitterRatio > 1 { cfg.Delivery.JitterRatio = 1 } if cfg.Security.DNSResolveTimeout <= 0 { cfg.Security.DNSResolveTimeout = defaultDNSResolveTimeoutMS } if len(cfg.Security.AllowedPorts) == 0 { cfg.Security.AllowedPorts = []int{443} } if !cfg.Security.RequireHTTPS { cfg.Security.RequireHTTPS = true } if cfg.Secrets.Static == nil { cfg.Secrets.Static = map[string]string{} } if strings.TrimSpace(cfg.Secrets.Vault.DefaultField) == "" { cfg.Secrets.Vault.DefaultField = defaultSecretsVaultField } } func (s *service) validate(cfg *Config) error { if cfg.Database == nil { return merrors.InvalidArgument("database configuration is required", "database") } if cfg.Messaging == nil { return merrors.InvalidArgument("messaging configuration is required", "messaging") } if strings.TrimSpace(string(cfg.Messaging.Driver)) == "" { return merrors.InvalidArgument("messaging.driver is required", "messaging.driver") } if cfg.Delivery.MinDelay() > cfg.Delivery.MaxDelay() { return merrors.InvalidArgument("delivery min delay must be <= max delay", "delivery.min_delay_ms", "delivery.max_delay_ms") } if cfg.Delivery.MaxAttempts < 1 { return merrors.InvalidArgument("delivery.max_attempts must be > 0", "delivery.max_attempts") } vaultAddress := strings.TrimSpace(cfg.Secrets.Vault.Address) vaultTokenEnv := strings.TrimSpace(cfg.Secrets.Vault.TokenEnv) vaultMountPath := strings.TrimSpace(cfg.Secrets.Vault.MountPath) hasVault := vaultAddress != "" || vaultTokenEnv != "" || vaultMountPath != "" if hasVault { if vaultAddress == "" { return merrors.InvalidArgument("secrets.vault.address is required when vault settings are configured", "secrets.vault.address") } if vaultTokenEnv == "" { return merrors.InvalidArgument("secrets.vault.token_env is required when vault settings are configured", "secrets.vault.token_env") } if vaultMountPath == "" { return merrors.InvalidArgument("secrets.vault.mount_path is required when vault settings are configured", "secrets.vault.mount_path") } } return nil }