1 Commits

Author SHA1 Message Date
b01383e530 Initial MVP commit 2026-03-19 19:52:13 -05:00
13 changed files with 1269 additions and 0 deletions

83
cmd/weatherapi/main.go Normal file
View File

@@ -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
}

13
config.yml Normal file
View File

@@ -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

12
go.mod
View File

@@ -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

6
go.sum Normal file
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

35
internal/core/service.go Normal file
View File

@@ -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)
}

View File

@@ -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}}

View File

@@ -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}}

View File

@@ -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}}

BIN
weatherapi Executable file

Binary file not shown.