Add weather API bundle adapter
This commit is contained in:
@@ -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.
|
||||
|
||||
69
docs/internal/weather-data.md
Normal file
69
docs/internal/weather-data.md
Normal 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.
|
||||
426
internal/adapters/weatherapi/client.go
Normal file
426
internal/adapters/weatherapi/client.go
Normal 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, ¤t); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
b.bundle.Current = ¤t
|
||||
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
|
||||
}
|
||||
348
internal/adapters/weatherapi/client_test.go
Normal file
348
internal/adapters/weatherapi/client_test.go
Normal 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
|
||||
}
|
||||
6
internal/adapters/weatherapi/testdata/alerts.json
vendored
Normal file
6
internal/adapters/weatherapi/testdata/alerts.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"data": {
|
||||
"asOf": "2026-05-29T14:00:00Z",
|
||||
"alerts": []
|
||||
}
|
||||
}
|
||||
10
internal/adapters/weatherapi/testdata/current.json
vendored
Normal file
10
internal/adapters/weatherapi/testdata/current.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"data": {
|
||||
"conditionText": "Partly cloudy",
|
||||
"isDay": true,
|
||||
"temperatureF": 75.9,
|
||||
"apparentTemperatureF": 76.1,
|
||||
"windSpeedMph": 10.7,
|
||||
"relativeHumidityPercent": 56
|
||||
}
|
||||
}
|
||||
16
internal/adapters/weatherapi/testdata/discussion.json
vendored
Normal file
16
internal/adapters/weatherapi/testdata/discussion.json
vendored
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
internal/adapters/weatherapi/testdata/hourly.json
vendored
Normal file
25
internal/adapters/weatherapi/testdata/hourly.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
20
internal/adapters/weatherapi/testdata/narrative.json
vendored
Normal file
20
internal/adapters/weatherapi/testdata/narrative.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
internal/adapters/weatherapi/testdata/observations.json
vendored
Normal file
13
internal/adapters/weatherapi/testdata/observations.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
64
internal/app/app_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
161
internal/forecast/bundle.go
Normal 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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user