refactor(providers): centralize provider-specific parsing and invariants

- Introduce internal/providers/nws with shared timestamp parsing used by both
  NWS sources and normalizers
- Migrate NWS observation source + normalizer to use the shared provider helper
  for consistent RFC3339/RFC3339Nano handling
- Introduce internal/providers/openweather with a shared URL invariant helper
  enforcing units=metric
- Remove duplicated OpenWeather URL validation logic from the observation source
- Align provider layering: move provider “contract/quirk” logic out of
  normalizers and into internal/providers
- Update normalizer and standards documentation to clearly distinguish:
  provider helpers (internal/providers) vs canonical mapping logic
  (internal/normalizers)

This refactor reduces duplication between sources and normalizers, clarifies
layering boundaries, and establishes a scalable pattern for future forecast
and alert implementations.
This commit is contained in:
2026-01-15 20:40:53 -06:00
parent a341aee5df
commit f13f43cf56
11 changed files with 86 additions and 46 deletions

View File

@@ -0,0 +1,8 @@
// Package nws contains provider-specific helper code for the National Weather Service
// used by both sources and normalizers.
//
// Rules:
// - No network I/O here (sources fetch; normalizers transform).
// - Keep helpers deterministic and easy to unit test.
// - Prefer putting provider quirks/parsing here when sources + normalizers both need it.
package nws

View File

@@ -0,0 +1,27 @@
package nws
import (
"fmt"
"strings"
"time"
)
// ParseTime parses NWS timestamps.
//
// NWS observation timestamps are typically RFC3339, sometimes with fractional seconds.
// We accept RFC3339Nano first, then RFC3339.
func ParseTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("empty time")
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("unsupported NWS timestamp format: %q", s)
}

View File

@@ -0,0 +1,8 @@
// Package openweather contains provider-specific helper code for OpenWeather used by
// both sources and normalizers.
//
// Rules:
// - No network I/O here.
// - Keep helpers deterministic and easy to unit test.
// - Put provider invariants here (e.g., units=metric enforcement).
package openweather

View File

@@ -0,0 +1,26 @@
package openweather
import (
"fmt"
"net/url"
"strings"
)
// RequireMetricUnits enforces weatherfeeder's OpenWeather invariant:
// the request URL must include units=metric (otherwise temperatures/winds/pressure differ).
func RequireMetricUnits(rawURL string) error {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return fmt.Errorf("invalid url %q: %w", rawURL, err)
}
units := strings.ToLower(strings.TrimSpace(u.Query().Get("units")))
if units != "metric" {
if units == "" {
units = "(missing; defaults to standard)"
}
return fmt.Errorf("url must include units=metric (got units=%s)", units)
}
return nil
}