Add weather API bundle adapter

This commit is contained in:
2026-05-29 17:06:39 +00:00
parent 8c065751c2
commit a885959d39
17 changed files with 1264 additions and 18 deletions

View File

@@ -19,11 +19,11 @@ See [examples/config.yml](../examples/config.yml).
## Reference
- `weather_api.base_url`: single Weather API endpoint base URL.
- `weather_api.base_url`: single Weather API endpoint base URL, required when fetching weather data.
- `weather_api.timeout`: HTTP timeout duration. Default: `10s`.
- `weather_api.precision`: numeric precision hint. Default: `1`.
- `weather_api.units`: Weather API units. Default: `us`.
- `weather_api.timezone`: report timezone. Default: `Chicago`.
- `weather_api.timezone`: report timezone. Accepts IANA names, configured aliases such as `Chicago` and `Stl`, US timezone abbreviations, and UTC offsets such as `-5` or `+09:30`. Default: `Chicago`.
- `weather_api.format`: Weather API response format. Default: `json`.
- `missing_source.default`: one of `error`, `warn`, or `none`. Default: `warn`.
- `missing_source.sources`: optional per-source missing-source policy overrides.

View File

@@ -0,0 +1,69 @@
# Weather Data Internals
This document describes the implemented weather data ingestion boundary.
## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one
configured weather API endpoint and assembles an `internal/forecast.Bundle`.
## Inputs and Outputs
Input:
- `config.Config` with `weather_api.base_url`, `format`, `units`, `timezone`,
`precision`, timeout, and missing-source policy.
Output:
- `forecast.Bundle` containing observation, current conditions, hourly forecast,
narrative forecast, alerts, discussion, stub source slots, provenance, and
source warnings.
## Boundaries
- The adapter performs HTTP calls and decoding only.
- Forecast derivation, daypart grouping, report periods, report rendering, and
`scriptorium` execution are outside this boundary.
- Hourly forecast data is required. Other missing or malformed source sections
use the configured missing-source policy.
## External Adapter
The adapter calls:
- `/observations`
- `/conditions/current`
- `/forecast/hourly`
- `/forecast/narrative`
- `/alerts/active`
- `/discussion`
Forecast routes use the full-product endpoints, not day-slice endpoints.
## State
`app.FetchAndSaveBundle` can save an inspectable bundle JSON file using an
atomic rename. No report state, snapshots, or prompt input packages are written
yet.
## Failure Behavior
- HTTP and envelope decode failures return actionable errors with endpoint
context.
- Missing hourly data fails the fetch.
- Missing or malformed optional sources follow `error`, `warn`, or `none`.
- Source identity uses SHA-256 over compacted raw `data` JSON.
## Tests
Inspect:
- `internal/adapters/weatherapi/client_test.go`
- `internal/app/app_test.go`
## Invariants
- Weather facts come from normalized source data.
- External API details stay inside `internal/adapters/weatherapi`.
- Source provenance and warnings remain inspectable for later briefing builders.

View File

@@ -0,0 +1,426 @@
// Package weatherapi adapts the internal weather API to forecast bundles.
package weatherapi
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
type Client struct {
baseURL *url.URL
httpClient *http.Client
units string
format string
timezone string
precision int
missingSource config.MissingSourceConfig
now func() time.Time
}
type Option func(*Client)
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) {
if httpClient != nil {
c.httpClient = httpClient
}
}
}
func WithClock(now func() time.Time) Option {
return func(c *Client) {
if now != nil {
c.now = now
}
}
}
func New(cfg config.Config, opts ...Option) (*Client, error) {
if strings.TrimSpace(cfg.WeatherAPI.BaseURL) == "" {
return nil, fmt.Errorf("weather_api.base_url is required")
}
baseURL, err := url.Parse(cfg.WeatherAPI.BaseURL)
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
}
timeout := cfg.WeatherAPI.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
client := &Client{
baseURL: baseURL,
httpClient: &http.Client{Timeout: timeout},
units: cfg.WeatherAPI.Units,
format: cfg.WeatherAPI.Format,
timezone: cfg.WeatherAPI.Timezone,
precision: cfg.WeatherAPI.Precision,
missingSource: config.MissingSourceConfig{
Default: cfg.MissingSource.Default,
Sources: cfg.MissingSource.Sources,
},
now: time.Now,
}
for _, opt := range opts {
opt(client)
}
return client, nil
}
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
fetchedAt := c.now()
builder := bundleBuilder{
client: c,
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt,
}
if err := builder.fetchObservation(ctx); err != nil {
return nil, err
}
if err := builder.fetchCurrent(ctx); err != nil {
return nil, err
}
if err := builder.fetchHourly(ctx); err != nil {
return nil, err
}
if err := builder.fetchNarrative(ctx); err != nil {
return nil, err
}
if err := builder.fetchAlerts(ctx); err != nil {
return nil, err
}
if err := builder.fetchDiscussion(ctx); err != nil {
return nil, err
}
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
return nil, err
}
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
return nil, err
}
return builder.bundle, nil
}
type bundleBuilder struct {
client *Client
bundle *forecast.Bundle
fetchedAt time.Time
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "observation data is missing", false)
}
var observation forecast.Observation
if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &observation.Timestamp
b.bundle.Observation = &observation
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "current conditions data is missing", false)
}
var current forecast.Current
if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false)
}
b.bundle.Current = &current
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "hourly forecast data is missing", true)
}
var hourly forecast.ForecastRun
if err := decodeSource(raw, &hourly); err != nil {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
}
if len(hourly.Periods) == 0 {
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
}
source.IssuedAt = &hourly.IssuedAt
source.UpdatedAt = hourly.UpdatedAt
b.bundle.Hourly = &hourly
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "narrative forecast data is missing", false)
}
var narrative forecast.ForecastRun
if err := decodeSource(raw, &narrative); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &narrative.IssuedAt
source.UpdatedAt = narrative.UpdatedAt
b.bundle.Narrative = &narrative
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "active alerts data is missing", false)
}
var alerts forecast.AlertRun
if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false)
}
alerts.Raw = append(json.RawMessage(nil), raw...)
if alerts.AsOf != nil {
source.IssuedAt = alerts.AsOf
}
b.bundle.Alerts = &alerts
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "forecast discussion data is missing", false)
}
var discussion forecast.Discussion
if err := decodeSource(raw, &discussion); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &discussion.IssuedAt
source.UpdatedAt = discussion.UpdatedAt
b.bundle.Discussion = &discussion
b.addSource(source)
return nil
}
func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := forecast.Source{
Name: sourceName,
FetchedAt: b.fetchedAt,
Missing: true,
}
return b.applyMissingPolicy(&source, "missing_source", message)
}
func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error {
source.Missing = true
if required {
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
}
return b.applyMissingPolicy(source, "missing_source", message)
}
func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error {
if required {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
}
source.Missing = true
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
}
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error {
policy := b.client.policyFor(source.Name)
if policy == config.MissingSourceError {
return fmt.Errorf("%s: %s", source.Name, message)
}
if policy == config.MissingSourceWarn {
warning := forecast.SourceWarning{
Source: source.Name,
Code: code,
Severity: "warning",
Message: message,
Endpoint: source.Endpoint,
CompletenessImpact: "source omitted from bundle",
}
source.Warnings = append(source.Warnings, warning)
b.bundle.Warnings = append(b.bundle.Warnings, warning)
}
b.addSource(*source)
return nil
}
func (b *bundleBuilder) addSource(source forecast.Source) {
b.bundle.Sources = append(b.bundle.Sources, source)
}
func (c *Client) policyFor(source string) config.MissingSourcePolicy {
if policy, ok := c.missingSource.Sources[source]; ok {
return policy
}
return c.missingSource.Default
}
type queryOptions struct {
precision bool
timezone bool
}
type envelope struct {
Data json.RawMessage `json:"data"`
}
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
}
source := forecast.Source{
Name: sourceName,
Endpoint: endpoint,
Query: queryMap(reqURL.Query()),
FetchedAt: c.now(),
}
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
source.Missing = true
return nil, source, nil
}
hash, err := sourceHash(env.Data)
if err != nil {
return env.Data, source, nil
}
source.DataSHA256 = hash
return env.Data, source, nil
}
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
reqURL := *c.baseURL
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
query := reqURL.Query()
query.Set("format", c.format)
query.Set("units", c.units)
if opts.precision {
query.Set("precision", strconv.Itoa(c.precision))
}
if opts.timezone {
query.Set("tz", c.timezone)
}
reqURL.RawQuery = query.Encode()
return &reqURL
}
func queryMap(values url.Values) map[string]string {
if len(values) == 0 {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
if len(value) > 0 {
out[key] = value[0]
}
}
return out
}
func decodeSource(raw json.RawMessage, target any) error {
if err := json.Unmarshal(raw, target); err != nil {
return err
}
return nil
}
func sourceHash(raw json.RawMessage) (string, error) {
var compact bytes.Buffer
if err := json.Compact(&compact, raw); err != nil {
return "", err
}
sum := sha256.Sum256(compact.Bytes())
return hex.EncodeToString(sum[:]), nil
}
func SaveBundle(path string, bundle *forecast.Bundle) error {
data, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return fmt.Errorf("marshal forecast bundle: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary bundle file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary bundle file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary bundle file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save bundle %q: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,348 @@
package weatherapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
func TestFetchBundleFromFixtures(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Observation == nil || bundle.Observation.StationID != "KSTL" {
t.Fatalf("Observation = %#v, want KSTL observation", bundle.Observation)
}
if bundle.Current == nil || bundle.Current.ConditionText != "Partly cloudy" {
t.Fatalf("Current = %#v, want current conditions", bundle.Current)
}
if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 {
t.Fatalf("Hourly = %#v, want one hourly period", bundle.Hourly)
}
if bundle.Narrative == nil || bundle.Narrative.Product != "narrative" {
t.Fatalf("Narrative = %#v, want narrative product", bundle.Narrative)
}
if bundle.Alerts == nil || bundle.Alerts.AsOf == nil {
t.Fatalf("Alerts = %#v, want alert run", bundle.Alerts)
}
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
}
if len(bundle.Warnings) != 2 {
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
}
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
}
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
}
}
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
for _, rawURL := range requested {
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing format=json or units=us", rawURL)
}
if strings.HasPrefix(rawURL, "/forecast/") {
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") {
t.Fatalf("forecast request %q missing precision or tz", rawURL)
}
}
}
}
func TestFetchBundleRecordsSourceHash(t *testing.T) {
server := fixtureServer(t, nil, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
observation := sourceByName(t, bundle.Sources, "observations")
want := hashFixtureData(t, "observations.json")
if observation.DataSHA256 != want {
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
}
}
func TestHTTPErrorIsActionable(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want HTTP error")
}
if !strings.Contains(err.Error(), "/conditions/current") || !strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want endpoint and status", err.Error())
}
}
func TestRequiredHourlyForecast(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want required hourly error")
}
if !strings.Contains(err.Error(), "hourly forecast data") {
t.Fatalf("error = %q, want hourly context", err.Error())
}
}
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct {
name string
policy config.MissingSourcePolicy
wantErr bool
wantWarns int
wantSource bool
}{
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
{name: "error", policy: config.MissingSourceError, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/observations": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
cfg := testConfig(server.URL + "/")
cfg.MissingSource.Default = tt.policy
cfg.MissingSource.Sources = map[string]config.MissingSourcePolicy{
"hourly": tt.policy,
}
client, err := New(cfg, WithClock(fixedNow))
if err != nil {
t.Fatalf("New() error = %v", err)
}
bundle, err := client.FetchBundle(context.Background())
if tt.wantErr {
if err == nil {
t.Fatal("FetchBundle() error = nil, want policy error")
}
return
}
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if len(bundle.Warnings) != tt.wantWarns {
t.Fatalf("Warnings length = %d, want %d", len(bundle.Warnings), tt.wantWarns)
}
if tt.wantSource {
source := sourceByName(t, bundle.Sources, "observations")
if !source.Missing {
t.Fatalf("observations source Missing = false, want true")
}
}
})
}
}
func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/conditions/current": {status: http.StatusOK, body: `{"data": {"temperatureF": "hot"}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
"current": config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
source := sourceByName(t, bundle.Sources, "current")
if !source.Missing || len(source.Warnings) != 1 {
t.Fatalf("current source = %#v, want missing source warning", source)
}
}
func TestContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer server.Close()
client := newTestClient(t, server.URL+"/", nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.FetchBundle(ctx)
if err == nil {
t.Fatal("FetchBundle() error = nil, want cancellation error")
}
}
func TestHTTPTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
}))
defer server.Close()
cfg := testConfig(server.URL + "/")
cfg.WeatherAPI.Timeout = time.Nanosecond
client, err := New(cfg, WithClock(fixedNow))
if err != nil {
t.Fatalf("New() error = %v", err)
}
_, err = client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want timeout error")
}
if !strings.Contains(err.Error(), "/observations") {
t.Fatalf("error = %q, want endpoint context", err.Error())
}
}
func TestSaveBundle(t *testing.T) {
server := fixtureServer(t, nil, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
path := filepath.Join(t.TempDir(), "nested", "bundle.json")
if err := SaveBundle(path, bundle); err != nil {
t.Fatalf("SaveBundle() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved bundle: %v", err)
}
if !strings.Contains(string(data), `"hourly"`) {
t.Fatalf("saved bundle missing hourly source:\n%s", string(data))
}
}
type handlerOverride struct {
status int
body string
}
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper()
fixtures := map[string]string{
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil {
*requested = append(*requested, r.URL.String())
}
if override, ok := overrides[r.URL.Path]; ok {
w.WriteHeader(override.status)
_, _ = w.Write([]byte(override.body))
return
}
name, ok := fixtures[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, filepath.Join("testdata", name))
}))
t.Cleanup(server.Close)
return server
}
func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]config.MissingSourcePolicy) *Client {
t.Helper()
cfg := testConfig(baseURL)
for source, policy := range sourcePolicies {
cfg.MissingSource.Sources[source] = policy
}
client, err := New(cfg, WithClock(fixedNow))
if err != nil {
t.Fatalf("New() error = %v", err)
}
return client
}
func testConfig(baseURL string) config.Config {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = baseURL
return cfg
}
func fixedNow() time.Time {
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
}
func containsPath(requested []string, path string) bool {
for _, rawURL := range requested {
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
return true
}
}
return false
}
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source {
t.Helper()
for _, source := range sources {
if source.Name == name {
return source
}
}
t.Fatalf("source %q not found in %#v", name, sources)
return forecast.Source{}
}
func hashFixtureData(t *testing.T, fixture string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", fixture))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var env envelope
if err := json.Unmarshal(data, &env); err != nil {
t.Fatalf("decode fixture envelope: %v", err)
}
hash, err := sourceHash(env.Data)
if err != nil {
t.Fatalf("hash fixture data: %v", err)
}
return hash
}

View File

@@ -0,0 +1,6 @@
{
"data": {
"asOf": "2026-05-29T14:00:00Z",
"alerts": []
}
}

View File

@@ -0,0 +1,10 @@
{
"data": {
"conditionText": "Partly cloudy",
"isDay": true,
"temperatureF": 75.9,
"apparentTemperatureF": 76.1,
"windSpeedMph": 10.7,
"relativeHumidityPercent": 56
}
}

View File

@@ -0,0 +1,16 @@
{
"data": {
"officeId": "LSX",
"officeName": "St. Louis",
"product": "discussion",
"issuedAt": "2026-05-29T09:25:00-05:00",
"keyMessages": [
"Scattered showers possible this evening.",
"Warmer temperatures this weekend."
],
"shortTerm": {
"title": "Short Term",
"narrative": "A weak boundary may trigger isolated showers."
}
}
}

View File

@@ -0,0 +1,25 @@
{
"data": {
"locationId": "nws-lsx-grid-90-74",
"locationName": "St. Louis, MO",
"issuedAt": "2026-05-29T10:30:00-05:00",
"updatedAt": "2026-05-29T10:45:00-05:00",
"product": "hourly",
"latitude": 38.63,
"longitude": -90.2,
"elevationFeet": 466,
"periods": [
{
"startTime": "2026-05-29T13:00:00-05:00",
"endTime": "2026-05-29T14:00:00-05:00",
"isDay": true,
"conditionCode": 3,
"textDescription": "Partly sunny",
"temperatureF": 81,
"windSpeedMph": 12,
"windGustMph": 20,
"probabilityOfPrecipitationPercent": 10
}
]
}
}

View File

@@ -0,0 +1,20 @@
{
"data": {
"locationId": "nws-lsx-grid-90-74",
"locationName": "St. Louis, MO",
"issuedAt": "2026-05-29T10:30:00-05:00",
"product": "narrative",
"periods": [
{
"startTime": "2026-05-29T13:00:00-05:00",
"endTime": "2026-05-29T19:00:00-05:00",
"name": "Today",
"isDay": true,
"textDescription": "Partly sunny, with a high near 81.",
"temperatureF": 81,
"windSpeedMph": 12,
"probabilityOfPrecipitationPercent": 10
}
]
}
}

View File

@@ -0,0 +1,13 @@
{
"data": {
"stationId": "KSTL",
"stationName": "St. Louis",
"timestamp": "2026-05-29T14:00:00Z",
"conditionCode": 3,
"isDay": true,
"textDescription": "Partly cloudy",
"temperatureF": 75.9,
"windSpeedMph": 10.7,
"relativeHumidityPercent": 56
}
}

View File

@@ -6,7 +6,9 @@ import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
type ReportKind string
@@ -40,6 +42,11 @@ type BatchRequest struct {
Batch BatchKind
}
type FetchBundleRequest struct {
Config config.Config
OutputPath string
}
func Generate(ctx context.Context, req GenerateRequest) error {
_ = ctx
_ = req
@@ -51,3 +58,29 @@ func RunBatch(ctx context.Context, req BatchRequest) error {
_ = req
return fmt.Errorf("run is not implemented")
}
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
client, err := weatherapi.New(req.Config)
if err != nil {
return nil, err
}
bundle, err := client.FetchBundle(ctx)
if err != nil {
return nil, err
}
return bundle, nil
}
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
bundle, err := FetchBundle(ctx, req)
if err != nil {
return nil, err
}
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil {
return nil, err
}
return bundle, nil
}

64
internal/app/app_test.go Normal file
View File

@@ -0,0 +1,64 @@
package app
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
func TestFetchAndSaveBundle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/observations":
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
case "/forecast/hourly":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
case "/forecast/narrative":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
path := filepath.Join(t.TempDir(), "bundle.json")
bundle, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: cfg, OutputPath: path})
if err != nil {
t.Fatalf("FetchAndSaveBundle() error = %v", err)
}
if bundle.Hourly == nil {
t.Fatal("Hourly = nil, want fetched bundle")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved bundle: %v", err)
}
if !strings.Contains(string(data), `"product": "hourly"`) {
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
}
}
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
_, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()})
if err == nil {
t.Fatal("FetchAndSaveBundle() error = nil, want output path error")
}
if !strings.Contains(err.Error(), "output path") {
t.Fatalf("error = %q, want output path context", err.Error())
}
}

View File

@@ -72,15 +72,15 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
}
func TestLoadAppliesOverrides(t *testing.T) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "UTC", Output: "./out"})
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.WeatherAPI.Units != "metric" {
t.Fatalf("Units = %q, want metric", cfg.WeatherAPI.Units)
}
if cfg.WeatherAPI.Timezone != "UTC" {
t.Fatalf("Timezone = %q, want UTC", cfg.WeatherAPI.Timezone)
if cfg.WeatherAPI.Timezone != "+09:30" {
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
}
if cfg.Reports.OutputDir != "./out" {
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)

View File

@@ -5,7 +5,8 @@ import (
"net/url"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func Validate(cfg Config) error {
@@ -27,7 +28,7 @@ func Validate(cfg Config) error {
if cfg.WeatherAPI.Timezone == "" {
return fmt.Errorf("weather_api.timezone is required")
}
if _, err := loadLocation(cfg.WeatherAPI.Timezone); err != nil {
if _, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone); err != nil {
return fmt.Errorf("weather_api.timezone %q is invalid: %w", cfg.WeatherAPI.Timezone, err)
}
if cfg.WeatherAPI.Format == "" {
@@ -78,17 +79,6 @@ func Validate(cfg Config) error {
return nil
}
func loadLocation(name string) (*time.Location, error) {
location, err := time.LoadLocation(name)
if err == nil {
return location, nil
}
if strings.Contains(name, "/") {
return nil, err
}
return time.LoadLocation("America/" + name)
}
func validatePolicy(name string, policy MissingSourcePolicy) error {
switch policy {
case MissingSourceError, MissingSourceWarn, MissingSourceNone:

161
internal/forecast/bundle.go Normal file
View File

@@ -0,0 +1,161 @@
// Package forecast defines normalized weather data consumed by report builders.
package forecast
import (
"encoding/json"
"time"
)
type Bundle struct {
FetchedAt time.Time `json:"fetchedAt"`
Observation *Observation `json:"observation,omitempty"`
Current *Current `json:"current,omitempty"`
Hourly *ForecastRun `json:"hourly,omitempty"`
Narrative *ForecastRun `json:"narrative,omitempty"`
Alerts *AlertRun `json:"alerts,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"`
Daily *ForecastRun `json:"daily,omitempty"`
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
Sources []Source `json:"sources"`
Warnings []SourceWarning `json:"warnings,omitempty"`
}
type Source struct {
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
Query map[string]string `json:"query,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []SourceWarning `json:"warnings,omitempty"`
}
type SourceWarning struct {
Source string `json:"source"`
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message"`
Endpoint string `json:"endpoint,omitempty"`
CompletenessImpact string `json:"completenessImpact,omitempty"`
}
type Observation struct {
StationID string `json:"stationId,omitempty"`
StationName string `json:"stationName,omitempty"`
Timestamp time.Time `json:"timestamp"`
ConditionCode *int `json:"conditionCode,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
TextDescription string `json:"textDescription,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
PresentWeather []json.RawMessage `json:"presentWeather,omitempty"`
}
type Current struct {
ConditionText string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
}
type ForecastRun struct {
LocationID string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
IssuedAt time.Time `json:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Product string `json:"product"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
ElevationMeters *float64 `json:"elevationMeters,omitempty"`
ElevationFeet *float64 `json:"elevationFeet,omitempty"`
Periods []ForecastPeriod `json:"periods"`
}
type ForecastPeriod struct {
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
ConditionCode *int `json:"conditionCode,omitempty"`
TextDescription string `json:"textDescription,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
TemperatureCMin *float64 `json:"temperatureCMin,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty"`
TemperatureCMax *float64 `json:"temperatureCMax,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty"`
SnowfallDepthMM *float64 `json:"snowfallDepthMM,omitempty"`
SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty"`
UVIndex *float64 `json:"uvIndex,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
}
type AlertRun struct {
AsOf *time.Time `json:"asOf,omitempty"`
Alerts []json.RawMessage `json:"alerts,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
type Discussion struct {
OfficeID string `json:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty"`
Product string `json:"product"`
IssuedAt time.Time `json:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
KeyMessages []string `json:"keyMessages,omitempty"`
ShortTerm *DiscussionSection `json:"shortTerm,omitempty"`
LongTerm *DiscussionSection `json:"longTerm,omitempty"`
}
type DiscussionSection struct {
Title string `json:"title,omitempty"`
Narrative string `json:"narrative,omitempty"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
}
type WeatherStory struct {
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}

View File

@@ -2,6 +2,8 @@ package timeutil
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
@@ -10,6 +12,12 @@ const DateLayout = "2006-01-02"
const LocalDateTimeLayout = "2006-01-02T15:04"
func LoadLocation(name string) (*time.Location, error) {
if location, ok := timezoneAliases[name]; ok {
return location, nil
}
if location, ok := parseUTCOffset(name); ok {
return location, nil
}
location, err := time.LoadLocation(name)
if err == nil {
return location, nil
@@ -24,6 +32,52 @@ func LoadLocation(name string) (*time.Location, error) {
return nil, fmt.Errorf("load timezone %q: %w", name, err)
}
var timezoneAliases = map[string]*time.Location{
"Chicago": mustLocation("America/Chicago"),
"Stl": mustLocation("America/Chicago"),
"EST": time.FixedZone("EST", -5*60*60),
"EDT": time.FixedZone("EDT", -4*60*60),
"CST": time.FixedZone("CST", -6*60*60),
"CDT": time.FixedZone("CDT", -5*60*60),
"MST": time.FixedZone("MST", -7*60*60),
"MDT": time.FixedZone("MDT", -6*60*60),
"PST": time.FixedZone("PST", -8*60*60),
"PDT": time.FixedZone("PDT", -7*60*60),
}
func mustLocation(name string) *time.Location {
location, err := time.LoadLocation(name)
if err != nil {
panic(err)
}
return location
}
var utcOffsetPattern = regexp.MustCompile(`^([+-])(\d{1,2})(?::?(\d{2}))?$`)
func parseUTCOffset(value string) (*time.Location, bool) {
matches := utcOffsetPattern.FindStringSubmatch(value)
if matches == nil {
return nil, false
}
hours, err := strconv.Atoi(matches[2])
if err != nil || hours > 23 {
return nil, false
}
minutes := 0
if matches[3] != "" {
minutes, err = strconv.Atoi(matches[3])
if err != nil || minutes > 59 {
return nil, false
}
}
offset := (hours*60 + minutes) * 60
if matches[1] == "-" {
offset = -offset
}
return time.FixedZone(value, offset), true
}
func LocalDate(now time.Time, location *time.Location) time.Time {
local := now.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)

View File

@@ -15,6 +15,17 @@ func TestLoadLocationAcceptsChicagoAlias(t *testing.T) {
}
}
func TestLoadLocationAcceptsWeatherAPITimezones(t *testing.T) {
tests := []string{"Stl", "CDT", "-5", "+09:30"}
for _, tt := range tests {
t.Run(tt, func(t *testing.T) {
if _, err := LoadLocation(tt); err != nil {
t.Fatalf("LoadLocation(%q) error = %v", tt, err)
}
})
}
}
func TestParseLocalDate(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
got, err := ParseLocalDate("2026-05-29", location)