byob-go-cli

Validate parsed config shape explicitly before trusting it

byob-input-validation.2 input-validation

Problem: yaml.Unmarshal([]byte(data), &cfg) succeeds with the zero value for any missing field and silently ignores unknown fields by default. Code downstream reaches into cfg.HTTP.Timeout expecting a positive duration and gets 0, which HTTP clients interpret as "no timeout." The bug surfaces hours later in production.

Idea: after parsing, every config struct has a Validate() error method that runs before the Factory hands the config out. Two discipline points make this work:

  • Reject unknown fields at decode time. yaml.NewDecoder(r).KnownFields(true) or json.NewDecoder(r).DisallowUnknownFields() — typos in the config fail loudly instead of being silently ignored.
  • Zero is a valid "unset" only where intended. Required fields use pointer types (Timeout *time.Duration) so a missing value is distinguishable from an explicit zero. Validate() dereferences and range-checks.

Prefer hand-rolled Validate() methods over struct-tag libraries (go-playground/validator). The tags move validation out of Go and into string literals; the methods keep logic co-located with the struct, show up in godoc, and let you compose validation naturally via substruct calls.

Tradeoffs: a few lines per config struct. Pays off the first time a deployment fails cleanly at startup instead of producing corrupted state in hour three.

When not to use: config structs with one scalar field. The ceremony doesn't earn its keep until there are 3+ fields.

Design

// internal/config/http.go
type HTTP struct {
    Endpoint string         `yaml:"endpoint"`
    Timeout  *time.Duration `yaml:"timeout"`    // pointer: "unset" vs "0" distinguishable
    Retries  int            `yaml:"retries"`
}

func (h *HTTP) Validate() error {
    if h.Endpoint == "" {
        return errors.New("http.endpoint is required")
    }
    if _, err := url.Parse(h.Endpoint); err != nil {
        return fmt.Errorf("http.endpoint: %w", err)
    }
    if h.Timeout == nil {
        return errors.New("http.timeout is required")
    }
    if *h.Timeout <= 0 {
        return errors.New("http.timeout must be positive")
    }
    if h.Retries < 0 || h.Retries > 10 {
        return fmt.Errorf("http.retries out of range: %d (0-10)", h.Retries)
    }
    return nil
}

type Config struct {
    HTTP HTTP `yaml:"http"`
    // ...
}

func (c *Config) Validate() error {
    if err := c.HTTP.Validate(); err != nil {
        return fmt.Errorf("http: %w", err)
    }
    // ...
    return nil
}

// internal/config/load.go
func Load(r io.Reader) (*Config, error) {
    dec := yaml.NewDecoder(r)
    dec.KnownFields(true) // reject unknown keys
    var c Config
    if err := dec.Decode(&c); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }
    if err := c.Validate(); err != nil {
        return nil, fmt.Errorf("config: %w", err)
    }
    return &c, nil
}