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

@@ -36,6 +36,10 @@
// 2. Provider-level shared helpers live under the provider directory:
// internal/normalizers/<provider>/
//
// Use this for provider-specific quirks that should be shared by BOTH sources
// and normalizers (time parsing, URL/unit invariants, ID normalization, etc.).
// Keep these helpers pure (no I/O) and easy to test.
//
// You may use multiple helper files (recommended) when it improves clarity:
// - types.go (provider JSON structs)
// - common.go (provider-shared helpers)

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/ejr/feedkit/event"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
)
@@ -46,11 +47,11 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation,
// Timestamp (RFC3339)
var ts time.Time
if s := strings.TrimSpace(parsed.Properties.Timestamp); s != "" {
t, err := time.Parse(time.RFC3339, s)
t, err := nwscommon.ParseTime(s)
if err != nil {
return model.WeatherObservation{}, time.Time{}, fmt.Errorf("invalid timestamp %q: %w", s, err)
}
ts = t
ts = t.UTC()
}
cloudLayers := make([]model.CloudLayer, 0, len(parsed.Properties.CloudLayers))

View File

@@ -1,19 +0,0 @@
// FILE: ./internal/normalizers/openmeteo/common.go
package openmeteo
import (
"time"
openmeteo "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo"
)
// parseOpenMeteoTime parses Open-Meteo timestamps.
//
// The actual parsing logic lives in internal/providers/openmeteo so both the
// source (envelope EffectiveAt / event ID) and normalizer (canonical payload)
// can share identical timestamp behavior.
//
// We keep this thin wrapper to avoid churn in the normalizer package.
func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) {
return openmeteo.ParseTime(s, tz, utcOffsetSeconds)
}

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/ejr/feedkit/event"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
omcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
)
@@ -54,7 +55,7 @@ func buildObservation(parsed omResponse) (model.WeatherObservation, time.Time, e
// Parse current.time.
var ts time.Time
if s := strings.TrimSpace(parsed.Current.Time); s != "" {
t, err := parseOpenMeteoTime(s, parsed.Timezone, parsed.UTCOffsetSeconds)
t, err := omcommon.ParseTime(s, parsed.Timezone, parsed.UTCOffsetSeconds)
if err != nil {
return model.WeatherObservation{}, time.Time{}, fmt.Errorf("parse time %q: %w", s, err)
}