diff --git a/cmd/weatherapi/main.go b/cmd/weatherapi/main.go new file mode 100644 index 0000000..d22d4f0 --- /dev/null +++ b/cmd/weatherapi/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + feedapp "gitea.maximumdirect.net/ejr/feedapi/app" + "gitea.maximumdirect.net/ejr/feedapi/config" + "gitea.maximumdirect.net/ejr/feedapi/db" + httpapi "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi" + wfpq "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/outbound/postgres" + "gitea.maximumdirect.net/ejr/weatherapi/internal/core" + + _ "github.com/lib/pq" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + cfgPath := flag.String("config", envOrDefault("WEATHERAPI_CONFIG", "config.yml"), "Path to config YAML") + flag.Parse() + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := run(ctx, *cfgPath); err != nil { + log.Fatalf("weatherapi failed: %v", err) + } +} + +func run(ctx context.Context, cfgPath string) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + if len(cfg.Databases) == 0 { + return fmt.Errorf("config.databases requires at least one entry") + } + + reg, err := db.OpenAll(cfg.Databases) + if err != nil { + return fmt.Errorf("open databases: %w", err) + } + defer func() { + if cerr := reg.Close(); cerr != nil { + log.Printf("database close error: %v", cerr) + } + }() + + primaryName := cfg.Databases[0].Name + primary, err := reg.Get(primaryName) + if err != nil { + return fmt.Errorf("select primary database %q: %w", primaryName, err) + } + + repo := wfpq.NewRepository(primary) + svc := core.NewService(repo) + defs := httpapi.Definitions(svc) + + a, err := feedapp.New(cfg, + feedapp.WithDBRegistry(reg), + feedapp.WithEndpoints(defs...), + ) + if err != nil { + return fmt.Errorf("build app: %w", err) + } + + return a.Start(ctx) +} + +func envOrDefault(key, fallback string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + return v +} diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..ef28323 --- /dev/null +++ b/config.yml @@ -0,0 +1,13 @@ +server: + listen_addr: ":8080" + default_format: json + +databases: + - name: weatherdb + driver: postgres + uri: postgres://weatherdb:5432/weatherdb?sslmode=disable + username: weatherdb + password: weatherdb + +templates: + base_dir: templates diff --git a/go.mod b/go.mod index 5433da3..da6d567 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module gitea.maximumdirect.net/ejr/weatherapi go 1.25.5 + +require ( + gitea.maximumdirect.net/ejr/feedapi v0.0.0 + gitea.maximumdirect.net/ejr/weatherfeeder v0.0.0 + github.com/lib/pq v1.10.9 +) + +require gopkg.in/yaml.v3 v3.0.1 // indirect + +replace gitea.maximumdirect.net/ejr/feedapi => ../feedapi + +replace gitea.maximumdirect.net/ejr/weatherfeeder => ../weatherfeeder diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c95a75b --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go new file mode 100644 index 0000000..45e7505 --- /dev/null +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -0,0 +1,76 @@ +package httpapi + +import ( + "context" + "net/http" + + "gitea.maximumdirect.net/ejr/feedapi/bind" + "gitea.maximumdirect.net/ejr/feedapi/endpoint" + "gitea.maximumdirect.net/ejr/feedapi/render" + "gitea.maximumdirect.net/ejr/feedapi/response" + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +// Service describes the weather use-cases needed by the HTTP adapter. +type Service interface { + LatestObservation(ctx context.Context) (*model.WeatherObservation, error) + LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) + LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) +} + +type emptyRequest struct{} + +func Definitions(svc Service) []endpoint.Definition { + return []endpoint.Definition{ + endpoint.GET( + "/observations", + bindFormatOnly, + func(ctx context.Context, _ emptyRequest) (any, error) { + obs, err := svc.LatestObservation(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: obs}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("observations.txt.tmpl"), + ), + endpoint.GET( + "/forecast/hourly", + bindFormatOnly, + func(ctx context.Context, _ emptyRequest) (any, error) { + run, err := svc.LatestHourlyForecast(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: run}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("forecast_hourly.txt.tmpl"), + ), + endpoint.GET( + "/alerts/active", + bindFormatOnly, + func(ctx context.Context, _ emptyRequest) (any, error) { + run, err := svc.LatestActiveAlerts(ctx) + if err != nil { + return nil, err + } + return response.Envelope{Data: run}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("alerts_active.txt.tmpl"), + ), + } +} + +func bindFormatOnly(r *http.Request) (emptyRequest, error) { + _, err := bind.CommonQueryParams(r, bind.QueryPolicy{ + AllowFormat: true, + RejectUnknown: true, + }) + if err != nil { + return emptyRequest{}, err + } + return emptyRequest{}, nil +} diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go new file mode 100644 index 0000000..e71c45e --- /dev/null +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -0,0 +1,195 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "text/template" + "time" + + "gitea.maximumdirect.net/ejr/feedapi/endpoint" + apierrors "gitea.maximumdirect.net/ejr/feedapi/errors" + "gitea.maximumdirect.net/ejr/feedapi/render" + "gitea.maximumdirect.net/ejr/feedapi/templates" + "gitea.maximumdirect.net/ejr/feedapi/transport/httpx" + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +type fakeService struct { + observation *model.WeatherObservation + forecast *model.WeatherForecastRun + alerts *model.WeatherAlertRun + err error +} + +func (s *fakeService) LatestObservation(context.Context) (*model.WeatherObservation, error) { + return s.observation, s.err +} + +func (s *fakeService) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) { + return s.forecast, s.err +} + +func (s *fakeService) LatestActiveAlerts(context.Context) (*model.WeatherAlertRun, error) { + return s.alerts, s.err +} + +func TestObservationsRejectUnknownQueryParameter(t *testing.T) { + h := newHandler(t, &fakeService{}, "/observations") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/observations?bogus=1", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + + var env apierrors.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode error envelope: %v", err) + } + if env.Error == nil || env.Error.Code != apierrors.CodeInvalidParameter { + t.Fatalf("expected invalid_parameter code, got %+v", env.Error) + } +} + +func TestObservationsNoDataReturnsNullEnvelopeData(t *testing.T) { + h := newHandler(t, &fakeService{}, "/observations") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/observations", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data *json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data != nil { + t.Fatalf("expected data null, got %s", string(*payload.Data)) + } +} + +func TestObservationsPopulatedJSONEnvelope(t *testing.T) { + now := time.Date(2026, 3, 19, 18, 0, 0, 0, time.UTC) + h := newHandler(t, &fakeService{ + observation: &model.WeatherObservation{ + StationID: "KSTL", + StationName: "St. Louis", + Timestamp: now, + }, + }, "/observations") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/observations", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data struct { + StationID string `json:"stationId"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data.StationID != "KSTL" { + t.Fatalf("expected stationId KSTL, got %q", payload.Data.StationID) + } +} + +func TestFormatNegotiationXMLAndText(t *testing.T) { + hXML := newHandler(t, &fakeService{alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}}, "/alerts/active") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil) + req.Header.Set("Accept", "application/xml") + hXML.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for xml request, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) + } + + hText := newHandler(t, &fakeService{forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly}}, "/forecast/hourly") + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=text", nil) + hText.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for text request, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { + t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Body.String(), "Forecast text") { + t.Fatalf("expected rendered text template body, got %q", w.Body.String()) + } +} + +func newHandler(t *testing.T, svc Service, path string) http.Handler { + t.Helper() + + def := definitionForPath(t, Definitions(svc), path) + return httpx.Adapt(def, httpx.Dependencies{ + Renderers: testRenderers(t), + DefaultFormat: render.FormatJSON, + }) +} + +func definitionForPath(t *testing.T, defs []endpoint.Definition, path string) endpoint.Definition { + t.Helper() + for _, def := range defs { + if def.Path == path { + return def + } + } + t.Fatalf("endpoint not found: %s", path) + return endpoint.Definition{} +} + +func testRenderers(t *testing.T) *render.Registry { + t.Helper() + + reg := render.NewRegistry() + if err := reg.Register(render.NewJSONRenderer()); err != nil { + t.Fatal(err) + } + if err := reg.Register(render.NewXMLRenderer()); err != nil { + t.Fatal(err) + } + + tmplReg := templates.NewRegistry() + for name, body := range map[string]string{ + "observations.txt.tmpl": "Observation text", + "forecast_hourly.txt.tmpl": "Forecast text", + "alerts_active.txt.tmpl": "Alerts text", + } { + tmpl, err := template.New(name).Parse(body) + if err != nil { + t.Fatal(err) + } + if err := tmplReg.Register(name, tmpl); err != nil { + t.Fatal(err) + } + } + if err := reg.Register(templates.NewRenderer(tmplReg)); err != nil { + t.Fatal(err) + } + + return reg +} diff --git a/internal/adapters/outbound/postgres/repository.go b/internal/adapters/outbound/postgres/repository.go new file mode 100644 index 0000000..a3ad2e5 --- /dev/null +++ b/internal/adapters/outbound/postgres/repository.go @@ -0,0 +1,688 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "gitea.maximumdirect.net/ejr/weatherapi/internal/core" + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +const ( + queryLatestObservation = ` +SELECT + event_id, + station_id, + station_name, + observed_at, + condition_code, + is_day, + text_description, + temperature_c, + dewpoint_c, + wind_direction_degrees, + wind_speed_kmh, + wind_gust_kmh, + barometric_pressure_pa, + visibility_meters, + relative_humidity_percent, + apparent_temperature_c +FROM observations +ORDER BY observed_at DESC, event_emitted_at DESC +LIMIT 1` + + queryObservationPresentWeather = ` +SELECT weather_index, raw_text +FROM observation_present_weather +WHERE event_id = $1 +ORDER BY weather_index ASC` + + queryLatestHourlyForecast = ` +SELECT + event_id, + location_id, + location_name, + issued_at, + updated_at, + product, + latitude, + longitude, + elevation_meters +FROM forecasts +WHERE product = 'hourly' +ORDER BY issued_at DESC, event_emitted_at DESC +LIMIT 1` + + queryForecastPeriods = ` +SELECT + period_index, + start_time, + end_time, + name, + is_day, + condition_code, + condition_text, + provider_raw_description, + text_description, + detailed_text, + icon_url, + temperature_c, + temperature_c_min, + temperature_c_max, + dewpoint_c, + relative_humidity_percent, + wind_direction_degrees, + wind_speed_kmh, + wind_gust_kmh, + barometric_pressure_pa, + visibility_meters, + apparent_temperature_c, + cloud_cover_percent, + probability_of_precipitation_percent, + precipitation_amount_mm, + snowfall_depth_mm, + uv_index +FROM forecast_periods +WHERE run_event_id = $1 +ORDER BY period_index ASC` + + queryLatestAlertRun = ` +SELECT + event_id, + location_id, + location_name, + as_of, + latitude, + longitude +FROM alert_runs +ORDER BY as_of DESC, event_emitted_at DESC +LIMIT 1` + + queryAlerts = ` +SELECT + alert_index, + alert_id, + event, + headline, + severity, + urgency, + certainty, + status, + message_type, + category, + response, + description, + instruction, + sent, + effective, + onset, + expires, + area_description, + sender_name +FROM alerts +WHERE run_event_id = $1 +ORDER BY alert_index ASC` + + queryAlertReferences = ` +SELECT + alert_index, + id, + identifier, + sender, + sent +FROM alert_references +WHERE run_event_id = $1 +ORDER BY alert_index ASC, reference_index ASC` +) + +// Repository is a Postgres-backed implementation of weatherapi read ports. +type Repository struct { + db *sql.DB +} + +var _ core.Repository = (*Repository)(nil) + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row observationParentRow + err := r.db.QueryRowContext(ctx, queryLatestObservation).Scan( + &row.EventID, + &row.StationID, + &row.StationName, + &row.ObservedAt, + &row.ConditionCode, + &row.IsDay, + &row.TextDescription, + &row.TemperatureC, + &row.DewpointC, + &row.WindDirectionDegrees, + &row.WindSpeedKmh, + &row.WindGustKmh, + &row.BarometricPressurePa, + &row.VisibilityMeters, + &row.RelativeHumidityPercent, + &row.ApparentTemperatureC, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest observation: %w", err) + } + + obs := mapObservationParentRow(row) + + presentWeather, err := r.loadObservationPresentWeather(ctx, row.EventID) + if err != nil { + return nil, err + } + obs.PresentWeather = presentWeather + + return &obs, nil +} + +func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row forecastParentRow + err := r.db.QueryRowContext(ctx, queryLatestHourlyForecast).Scan( + &row.EventID, + &row.LocationID, + &row.LocationName, + &row.IssuedAt, + &row.UpdatedAt, + &row.Product, + &row.Latitude, + &row.Longitude, + &row.ElevationMeters, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest hourly forecast: %w", err) + } + + run := mapForecastParentRow(row) + + periods, err := r.loadForecastPeriods(ctx, row.EventID) + if err != nil { + return nil, err + } + run.Periods = periods + + return &run, nil +} + +func (r *Repository) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row alertRunParentRow + err := r.db.QueryRowContext(ctx, queryLatestAlertRun).Scan( + &row.EventID, + &row.LocationID, + &row.LocationName, + &row.AsOf, + &row.Latitude, + &row.Longitude, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest alert run: %w", err) + } + + run := mapAlertRunParentRow(row) + + alerts, err := r.loadAlerts(ctx, row.EventID) + if err != nil { + return nil, err + } + run.Alerts = alerts + + return &run, nil +} + +func (r *Repository) loadObservationPresentWeather(ctx context.Context, eventID string) ([]model.PresentWeather, error) { + rows, err := r.db.QueryContext(ctx, queryObservationPresentWeather, eventID) + if err != nil { + return nil, fmt.Errorf("query observation present weather: %w", err) + } + defer rows.Close() + + out := make([]model.PresentWeather, 0) + for rows.Next() { + var row observationPresentWeatherRow + if err := rows.Scan(&row.WeatherIndex, &row.RawText); err != nil { + return nil, fmt.Errorf("scan observation present weather row: %w", err) + } + pw, err := mapObservationPresentWeatherRow(row) + if err != nil { + return nil, fmt.Errorf("decode observation present weather row (index=%d): %w", row.WeatherIndex, err) + } + out = append(out, pw) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate observation present weather rows: %w", err) + } + return out, nil +} + +func (r *Repository) loadForecastPeriods(ctx context.Context, eventID string) ([]model.WeatherForecastPeriod, error) { + rows, err := r.db.QueryContext(ctx, queryForecastPeriods, eventID) + if err != nil { + return nil, fmt.Errorf("query forecast periods: %w", err) + } + defer rows.Close() + + out := make([]model.WeatherForecastPeriod, 0) + for rows.Next() { + var row forecastPeriodRow + if err := rows.Scan( + &row.PeriodIndex, + &row.StartTime, + &row.EndTime, + &row.Name, + &row.IsDay, + &row.ConditionCode, + &row.ConditionText, + &row.ProviderRawDescription, + &row.TextDescription, + &row.DetailedText, + &row.IconURL, + &row.TemperatureC, + &row.TemperatureCMin, + &row.TemperatureCMax, + &row.DewpointC, + &row.RelativeHumidityPercent, + &row.WindDirectionDegrees, + &row.WindSpeedKmh, + &row.WindGustKmh, + &row.BarometricPressurePa, + &row.VisibilityMeters, + &row.ApparentTemperatureC, + &row.CloudCoverPercent, + &row.ProbabilityOfPrecipitationPercent, + &row.PrecipitationAmountMM, + &row.SnowfallDepthMM, + &row.UVIndex, + ); err != nil { + return nil, fmt.Errorf("scan forecast period row: %w", err) + } + out = append(out, mapForecastPeriodRow(row)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate forecast period rows: %w", err) + } + return out, nil +} + +func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.WeatherAlert, error) { + alertsRows, err := r.db.QueryContext(ctx, queryAlerts, eventID) + if err != nil { + return nil, fmt.Errorf("query alerts: %w", err) + } + defer alertsRows.Close() + + indexedAlerts := make([]indexedAlert, 0) + for alertsRows.Next() { + var row alertRow + if err := alertsRows.Scan( + &row.AlertIndex, + &row.AlertID, + &row.Event, + &row.Headline, + &row.Severity, + &row.Urgency, + &row.Certainty, + &row.Status, + &row.MessageType, + &row.Category, + &row.Response, + &row.Description, + &row.Instruction, + &row.Sent, + &row.Effective, + &row.Onset, + &row.Expires, + &row.AreaDescription, + &row.SenderName, + ); err != nil { + return nil, fmt.Errorf("scan alerts row: %w", err) + } + indexedAlerts = append(indexedAlerts, mapAlertRow(row)) + } + if err := alertsRows.Err(); err != nil { + return nil, fmt.Errorf("iterate alerts rows: %w", err) + } + + referenceRows, err := r.db.QueryContext(ctx, queryAlertReferences, eventID) + if err != nil { + return nil, fmt.Errorf("query alert references: %w", err) + } + defer referenceRows.Close() + + indexedReferences := make([]indexedAlertReference, 0) + for referenceRows.Next() { + var row alertReferenceRow + if err := referenceRows.Scan( + &row.AlertIndex, + &row.ID, + &row.Identifier, + &row.Sender, + &row.Sent, + ); err != nil { + return nil, fmt.Errorf("scan alert reference row: %w", err) + } + indexedReferences = append(indexedReferences, mapAlertReferenceRow(row)) + } + if err := referenceRows.Err(); err != nil { + return nil, fmt.Errorf("iterate alert reference rows: %w", err) + } + + return attachAlertReferences(indexedAlerts, indexedReferences), nil +} + +type observationParentRow struct { + EventID string + StationID sql.NullString + StationName sql.NullString + ObservedAt time.Time + ConditionCode int + IsDay sql.NullBool + TextDescription sql.NullString + TemperatureC sql.NullFloat64 + DewpointC sql.NullFloat64 + WindDirectionDegrees sql.NullFloat64 + WindSpeedKmh sql.NullFloat64 + WindGustKmh sql.NullFloat64 + BarometricPressurePa sql.NullFloat64 + VisibilityMeters sql.NullFloat64 + RelativeHumidityPercent sql.NullFloat64 + ApparentTemperatureC sql.NullFloat64 +} + +func mapObservationParentRow(row observationParentRow) model.WeatherObservation { + return model.WeatherObservation{ + StationID: stringValue(row.StationID), + StationName: stringValue(row.StationName), + Timestamp: row.ObservedAt.UTC(), + ConditionCode: model.WMOCode(row.ConditionCode), + IsDay: boolPtr(row.IsDay), + TextDescription: stringValue(row.TextDescription), + TemperatureC: float64Ptr(row.TemperatureC), + DewpointC: float64Ptr(row.DewpointC), + WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees), + WindSpeedKmh: float64Ptr(row.WindSpeedKmh), + WindGustKmh: float64Ptr(row.WindGustKmh), + BarometricPressurePa: float64Ptr(row.BarometricPressurePa), + VisibilityMeters: float64Ptr(row.VisibilityMeters), + RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent), + ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), + } +} + +type observationPresentWeatherRow struct { + WeatherIndex int + RawText sql.NullString +} + +func mapObservationPresentWeatherRow(row observationPresentWeatherRow) (model.PresentWeather, error) { + if !row.RawText.Valid || strings.TrimSpace(row.RawText.String) == "" { + return model.PresentWeather{}, nil + } + + var raw map[string]any + if err := json.Unmarshal([]byte(row.RawText.String), &raw); err != nil { + return model.PresentWeather{}, err + } + return model.PresentWeather{Raw: raw}, nil +} + +type forecastParentRow struct { + EventID string + LocationID sql.NullString + LocationName sql.NullString + IssuedAt time.Time + UpdatedAt sql.NullTime + Product string + Latitude sql.NullFloat64 + Longitude sql.NullFloat64 + ElevationMeters sql.NullFloat64 +} + +func mapForecastParentRow(row forecastParentRow) model.WeatherForecastRun { + return model.WeatherForecastRun{ + LocationID: stringValue(row.LocationID), + LocationName: stringValue(row.LocationName), + IssuedAt: row.IssuedAt.UTC(), + UpdatedAt: timePtr(row.UpdatedAt), + Product: model.ForecastProduct(row.Product), + Latitude: float64Ptr(row.Latitude), + Longitude: float64Ptr(row.Longitude), + ElevationMeters: float64Ptr(row.ElevationMeters), + } +} + +type forecastPeriodRow struct { + PeriodIndex int + StartTime time.Time + EndTime time.Time + Name sql.NullString + IsDay sql.NullBool + ConditionCode int + ConditionText sql.NullString + ProviderRawDescription sql.NullString + TextDescription sql.NullString + DetailedText sql.NullString + IconURL sql.NullString + TemperatureC sql.NullFloat64 + TemperatureCMin sql.NullFloat64 + TemperatureCMax sql.NullFloat64 + DewpointC sql.NullFloat64 + RelativeHumidityPercent sql.NullFloat64 + WindDirectionDegrees sql.NullFloat64 + WindSpeedKmh sql.NullFloat64 + WindGustKmh sql.NullFloat64 + BarometricPressurePa sql.NullFloat64 + VisibilityMeters sql.NullFloat64 + ApparentTemperatureC sql.NullFloat64 + CloudCoverPercent sql.NullFloat64 + ProbabilityOfPrecipitationPercent sql.NullFloat64 + PrecipitationAmountMM sql.NullFloat64 + SnowfallDepthMM sql.NullFloat64 + UVIndex sql.NullFloat64 +} + +func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod { + return model.WeatherForecastPeriod{ + StartTime: row.StartTime.UTC(), + EndTime: row.EndTime.UTC(), + Name: stringValue(row.Name), + IsDay: boolPtr(row.IsDay), + ConditionCode: model.WMOCode(row.ConditionCode), + ConditionText: stringValue(row.ConditionText), + ProviderRawDescription: stringValue(row.ProviderRawDescription), + TextDescription: stringValue(row.TextDescription), + DetailedText: stringValue(row.DetailedText), + IconURL: stringValue(row.IconURL), + TemperatureC: float64Ptr(row.TemperatureC), + TemperatureCMin: float64Ptr(row.TemperatureCMin), + TemperatureCMax: float64Ptr(row.TemperatureCMax), + DewpointC: float64Ptr(row.DewpointC), + RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent), + WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees), + WindSpeedKmh: float64Ptr(row.WindSpeedKmh), + WindGustKmh: float64Ptr(row.WindGustKmh), + BarometricPressurePa: float64Ptr(row.BarometricPressurePa), + VisibilityMeters: float64Ptr(row.VisibilityMeters), + ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), + CloudCoverPercent: float64Ptr(row.CloudCoverPercent), + ProbabilityOfPrecipitationPercent: float64Ptr(row.ProbabilityOfPrecipitationPercent), + PrecipitationAmountMm: float64Ptr(row.PrecipitationAmountMM), + SnowfallDepthMM: float64Ptr(row.SnowfallDepthMM), + UVIndex: float64Ptr(row.UVIndex), + } +} + +type alertRunParentRow struct { + EventID string + LocationID sql.NullString + LocationName sql.NullString + AsOf time.Time + Latitude sql.NullFloat64 + Longitude sql.NullFloat64 +} + +func mapAlertRunParentRow(row alertRunParentRow) model.WeatherAlertRun { + return model.WeatherAlertRun{ + LocationID: stringValue(row.LocationID), + LocationName: stringValue(row.LocationName), + AsOf: row.AsOf.UTC(), + Latitude: float64Ptr(row.Latitude), + Longitude: float64Ptr(row.Longitude), + } +} + +type alertRow struct { + AlertIndex int + AlertID string + Event sql.NullString + Headline sql.NullString + Severity sql.NullString + Urgency sql.NullString + Certainty sql.NullString + Status sql.NullString + MessageType sql.NullString + Category sql.NullString + Response sql.NullString + Description sql.NullString + Instruction sql.NullString + Sent sql.NullTime + Effective sql.NullTime + Onset sql.NullTime + Expires sql.NullTime + AreaDescription sql.NullString + SenderName sql.NullString +} + +type indexedAlert struct { + Index int + Alert model.WeatherAlert +} + +func mapAlertRow(row alertRow) indexedAlert { + return indexedAlert{ + Index: row.AlertIndex, + Alert: model.WeatherAlert{ + ID: row.AlertID, + Event: stringValue(row.Event), + Headline: stringValue(row.Headline), + Severity: stringValue(row.Severity), + Urgency: stringValue(row.Urgency), + Certainty: stringValue(row.Certainty), + Status: stringValue(row.Status), + MessageType: stringValue(row.MessageType), + Category: stringValue(row.Category), + Response: stringValue(row.Response), + Description: stringValue(row.Description), + Instruction: stringValue(row.Instruction), + Sent: timePtr(row.Sent), + Effective: timePtr(row.Effective), + Onset: timePtr(row.Onset), + Expires: timePtr(row.Expires), + AreaDescription: stringValue(row.AreaDescription), + SenderName: stringValue(row.SenderName), + }, + } +} + +type alertReferenceRow struct { + AlertIndex int + ID sql.NullString + Identifier sql.NullString + Sender sql.NullString + Sent sql.NullTime +} + +type indexedAlertReference struct { + AlertIndex int + Reference model.AlertReference +} + +func mapAlertReferenceRow(row alertReferenceRow) indexedAlertReference { + return indexedAlertReference{ + AlertIndex: row.AlertIndex, + Reference: model.AlertReference{ + ID: stringValue(row.ID), + Identifier: stringValue(row.Identifier), + Sender: stringValue(row.Sender), + Sent: timePtr(row.Sent), + }, + } +} + +func attachAlertReferences(alerts []indexedAlert, references []indexedAlertReference) []model.WeatherAlert { + refsByAlertIndex := make(map[int][]model.AlertReference, len(alerts)) + for _, ref := range references { + refsByAlertIndex[ref.AlertIndex] = append(refsByAlertIndex[ref.AlertIndex], ref.Reference) + } + + out := make([]model.WeatherAlert, 0, len(alerts)) + for _, alert := range alerts { + mapped := alert.Alert + if refs := refsByAlertIndex[alert.Index]; len(refs) > 0 { + mapped.References = refs + } + out = append(out, mapped) + } + return out +} + +func stringValue(v sql.NullString) string { + if !v.Valid { + return "" + } + return v.String +} + +func boolPtr(v sql.NullBool) *bool { + if !v.Valid { + return nil + } + b := v.Bool + return &b +} + +func float64Ptr(v sql.NullFloat64) *float64 { + if !v.Valid { + return nil + } + f := v.Float64 + return &f +} + +func timePtr(v sql.NullTime) *time.Time { + if !v.Valid { + return nil + } + t := v.Time.UTC() + return &t +} diff --git a/internal/adapters/outbound/postgres/repository_test.go b/internal/adapters/outbound/postgres/repository_test.go new file mode 100644 index 0000000..aeb1080 --- /dev/null +++ b/internal/adapters/outbound/postgres/repository_test.go @@ -0,0 +1,112 @@ +package postgres + +import ( + "database/sql" + "testing" + "time" +) + +func TestMapObservationParentRowNullables(t *testing.T) { + observedAt := time.Date(2026, 3, 19, 23, 45, 0, 0, time.FixedZone("CST", -6*3600)) + isDay := true + temp := 18.25 + + obs := mapObservationParentRow(observationParentRow{ + StationID: sql.NullString{String: "KSTL", Valid: true}, + StationName: sql.NullString{String: "St. Louis", Valid: true}, + ObservedAt: observedAt, + ConditionCode: 2, + IsDay: sql.NullBool{Bool: isDay, Valid: true}, + TemperatureC: sql.NullFloat64{Float64: temp, Valid: true}, + TextDescription: sql.NullString{String: "Partly Cloudy", Valid: true}, + }) + + if obs.StationID != "KSTL" { + t.Fatalf("expected station id KSTL, got %q", obs.StationID) + } + if obs.IsDay == nil || !*obs.IsDay { + t.Fatalf("expected isDay pointer true, got %v", obs.IsDay) + } + if obs.TemperatureC == nil || *obs.TemperatureC != temp { + t.Fatalf("expected temperature %v, got %v", temp, obs.TemperatureC) + } + if obs.DewpointC != nil { + t.Fatalf("expected nil dewpoint, got %v", *obs.DewpointC) + } + if got := obs.Timestamp.Location().String(); got != "UTC" { + t.Fatalf("expected UTC timestamp, got %s", got) + } +} + +func TestMapObservationPresentWeatherRow(t *testing.T) { + row := observationPresentWeatherRow{ + WeatherIndex: 1, + RawText: sql.NullString{String: `{"code":61,"text":"rain"}`, Valid: true}, + } + + pw, err := mapObservationPresentWeatherRow(row) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw.Raw == nil { + t.Fatalf("expected raw map to be populated") + } + if got, ok := pw.Raw["text"].(string); !ok || got != "rain" { + t.Fatalf("expected raw text rain, got %#v", pw.Raw["text"]) + } +} + +func TestMapForecastPeriodRowNullables(t *testing.T) { + start := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) + end := start.Add(1 * time.Hour) + period := mapForecastPeriodRow(forecastPeriodRow{ + StartTime: start, + EndTime: end, + ConditionCode: 80, + Name: sql.NullString{String: "Midnight", Valid: true}, + TemperatureC: sql.NullFloat64{Float64: 12.5, Valid: true}, + TemperatureCMin: sql.NullFloat64{Valid: false}, + }) + + if period.Name != "Midnight" { + t.Fatalf("expected period name Midnight, got %q", period.Name) + } + if period.TemperatureC == nil || *period.TemperatureC != 12.5 { + t.Fatalf("expected temperature pointer 12.5, got %v", period.TemperatureC) + } + if period.TemperatureCMin != nil { + t.Fatalf("expected nil TemperatureCMin, got %v", *period.TemperatureCMin) + } + if !period.StartTime.Equal(start) || !period.EndTime.Equal(end) { + t.Fatalf("unexpected time range: %s - %s", period.StartTime, period.EndTime) + } +} + +func TestAttachAlertReferencesPreservesOrder(t *testing.T) { + sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC) + sent2 := sent1.Add(10 * time.Minute) + + alerts := []indexedAlert{ + {Index: 4, Alert: mapAlertRow(alertRow{AlertIndex: 4, AlertID: "a-4"}).Alert}, + {Index: 9, Alert: mapAlertRow(alertRow{AlertIndex: 9, AlertID: "a-9"}).Alert}, + } + references := []indexedAlertReference{ + mapAlertReferenceRow(alertReferenceRow{AlertIndex: 4, Identifier: sql.NullString{String: "r1", Valid: true}, Sent: sql.NullTime{Time: sent1, Valid: true}}), + mapAlertReferenceRow(alertReferenceRow{AlertIndex: 4, Identifier: sql.NullString{String: "r2", Valid: true}, Sent: sql.NullTime{Time: sent2, Valid: true}}), + mapAlertReferenceRow(alertReferenceRow{AlertIndex: 9, Identifier: sql.NullString{String: "r9", Valid: true}}), + } + + out := attachAlertReferences(alerts, references) + if len(out) != 2 { + t.Fatalf("expected 2 alerts, got %d", len(out)) + } + if len(out[0].References) != 2 { + t.Fatalf("expected first alert to have 2 references, got %d", len(out[0].References)) + } + if out[0].References[0].Identifier != "r1" || out[0].References[1].Identifier != "r2" { + t.Fatalf("unexpected first alert reference order: %+v", out[0].References) + } + if len(out[1].References) != 1 || out[1].References[0].Identifier != "r9" { + t.Fatalf("unexpected second alert references: %+v", out[1].References) + } +} diff --git a/internal/core/service.go b/internal/core/service.go new file mode 100644 index 0000000..fab994c --- /dev/null +++ b/internal/core/service.go @@ -0,0 +1,35 @@ +package core + +import ( + "context" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +// Repository defines outbound data access used by weatherapi use cases. +type Repository interface { + LatestObservation(ctx context.Context) (*model.WeatherObservation, error) + LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) + LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) +} + +// Service provides weather read use-cases. +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) { + return s.repo.LatestObservation(ctx) +} + +func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) { + return s.repo.LatestHourlyForecast(ctx) +} + +func (s *Service) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) { + return s.repo.LatestActiveAlerts(ctx) +} diff --git a/templates/alerts_active.txt.tmpl b/templates/alerts_active.txt.tmpl new file mode 100644 index 0000000..60278cd --- /dev/null +++ b/templates/alerts_active.txt.tmpl @@ -0,0 +1,20 @@ +{{- if .Data -}} +Active Alerts +As Of: {{.Data.AsOf}} +Alerts: {{len .Data.Alerts}} +{{- range $i, $alert := .Data.Alerts}} + +[{{$i}}] {{$alert.ID}} +{{- if $alert.Headline}} +Headline: {{$alert.Headline}} +{{- end}} +{{- if $alert.Severity}} +Severity: {{$alert.Severity}} +{{- end}} +{{- if $alert.Expires}} +Expires: {{$alert.Expires}} +{{- end}} +{{- end}} +{{- else -}} +No active alerts data available. +{{- end}} diff --git a/templates/forecast_hourly.txt.tmpl b/templates/forecast_hourly.txt.tmpl new file mode 100644 index 0000000..fda7df8 --- /dev/null +++ b/templates/forecast_hourly.txt.tmpl @@ -0,0 +1,17 @@ +{{- if .Data -}} +Hourly Forecast +Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}} +Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}} +Issued At: {{.Data.IssuedAt}} +Periods: {{len .Data.Periods}} +{{- range $i, $period := .Data.Periods}} + +[{{$i}}] {{$period.StartTime}} -> {{$period.EndTime}} +Condition Code: {{$period.ConditionCode}} +{{- if $period.TextDescription}} +Summary: {{$period.TextDescription}} +{{- end}} +{{- end}} +{{- else -}} +No hourly forecast data available. +{{- end}} diff --git a/templates/observations.txt.tmpl b/templates/observations.txt.tmpl new file mode 100644 index 0000000..e37c449 --- /dev/null +++ b/templates/observations.txt.tmpl @@ -0,0 +1,12 @@ +{{- if .Data -}} +Observation +Station ID: {{if .Data.StationID}}{{.Data.StationID}}{{else}}n/a{{end}} +Station Name: {{if .Data.StationName}}{{.Data.StationName}}{{else}}n/a{{end}} +Timestamp: {{.Data.Timestamp}} +Condition Code: {{.Data.ConditionCode}} +{{- if .Data.TextDescription}} +Summary: {{.Data.TextDescription}} +{{- end}} +{{- else -}} +No observation data available. +{{- end}} diff --git a/weatherapi b/weatherapi new file mode 100755 index 0000000..bfb8253 Binary files /dev/null and b/weatherapi differ