Added a /conditions/current endpoint with computed best-guess values for current conditions
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-03-17 15:51:00 -05:00
parent e3aab86376
commit cb316c228a
19 changed files with 1146 additions and 358 deletions

View File

@@ -22,13 +22,18 @@ Example:
## Endpoints ## Endpoints
- `GET /observations/current` - `GET /conditions/current`
- `GET /observations?count=<1..100>&units=<us|metric>`
- `GET /forecast?timestamp=<RFC3339 timestamp>` - `GET /forecast?timestamp=<RFC3339 timestamp>`
- `GET /alerts/current` - `GET /alerts/current`
By default, API output is US units for temperature and wind speed: Default unit behavior:
- temperature fields: Fahrenheit (`*F`) - `/conditions/current`: US units
- wind fields: mph (`*Mph`) - `/observations`: metric units
- `/forecast`: US units
`/conditions/current`, `/observations`, and `/forecast` support `?units=us|metric`.
`/observations` defaults to `count=5` and validates `count` strictly in range `1..100`.
## Run ## Run

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/httpapi" "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/httpapi"
adapterpg "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/postgres" adapterpg "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/postgres"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/conditions"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
@@ -51,7 +52,7 @@ func main() {
log.Fatalf("unsupported datasource type %T for driver %q", ds, dbCfg.Driver) log.Fatalf("unsupported datasource type %T for driver %q", ds, dbCfg.Driver)
} }
converter, err := newDefaultConverter() unitRegistry, err := newUnitRegistry()
if err != nil { if err != nil {
log.Fatalf("configure units: %v", err) log.Fatalf("configure units: %v", err)
} }
@@ -60,18 +61,19 @@ func main() {
fcRepo := adapterpg.NewForecastRepository(pgDataSource.Pool()) fcRepo := adapterpg.NewForecastRepository(pgDataSource.Pool())
alertRepo := adapterpg.NewAlertRepository(pgDataSource.Pool()) alertRepo := adapterpg.NewAlertRepository(pgDataSource.Pool())
obsSvc := observations.NewService(obsRepo, converter, constants.ObservationWindow) obsSvc := observations.NewService(obsRepo, unitRegistry)
fcSvc := forecasts.NewService(fcRepo, converter, constants.ForecastQueryLimit) conditionsSvc := conditions.NewService(obsRepo, unitRegistry, constants.ObservationWindow)
fcSvc := forecasts.NewService(fcRepo, unitRegistry, constants.ForecastQueryLimit)
alertsSvc := alerts.NewService(alertRepo) alertsSvc := alerts.NewService(alertRepo)
server := httpapi.NewServer(obsSvc, fcSvc, alertsSvc) server := httpapi.NewServer(obsSvc, conditionsSvc, fcSvc, alertsSvc)
httpServer := &http.Server{ httpServer := &http.Server{
Addr: *addr, Addr: *addr,
Handler: server.Handler(), Handler: server.Handler(),
} }
log.Printf("weatherapi listening on %s (db=%s driver=%s units=%s)", *addr, dbCfg.Name, dbCfg.Driver, constants.DefaultOutputUnitSystem) log.Printf("weatherapi listening on %s (db=%s driver=%s units=per-request)", *addr, dbCfg.Name, dbCfg.Driver)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err) log.Fatalf("http server: %v", err)
} }
@@ -92,11 +94,19 @@ func chooseDatabase(cfg *config.Config, name string) (config.DatabaseConfig, err
return db, nil return db, nil
} }
func newDefaultConverter() (units.Converter, error) { func newUnitRegistry() (*units.Registry, error) {
switch constants.DefaultOutputUnitSystem { registry := units.NewRegistry()
case "us": if err := registry.Register(units.StaticFactory{
return units.USConverter{}, nil UnitSystem: constants.UnitSystemUS,
default: Converter: units.USConverter{},
return nil, fmt.Errorf("unsupported default output unit system %q", constants.DefaultOutputUnitSystem) }); err != nil {
return nil, err
} }
if err := registry.Register(units.StaticFactory{
UnitSystem: constants.UnitSystemMetric,
Converter: units.MetricConverter{},
}); err != nil {
return nil, err
}
return registry, nil
} }

1
go.mod
View File

@@ -3,6 +3,7 @@ module gitea.maximumdirect.net/ejr/weatherapi
go 1.25.5 go 1.25.5
require ( require (
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2
github.com/jackc/pgx/v5 v5.8.0 github.com/jackc/pgx/v5 v5.8.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

2
go.sum
View File

@@ -1,3 +1,5 @@
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2 h1:GJdZ9x54HLTPidPGhD/dmq7efvGjBltK1ywCIRU1w6c=
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2/go.mod h1:P7rP7XftJjBFzNEIkAiX+z2YqXOtnZjy3BaF6kX+sO4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

View File

@@ -3,21 +3,30 @@ package httpapi
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv"
"strings"
"time" "time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/conditions"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/timeparse" "gitea.maximumdirect.net/ejr/weatherapi/internal/platform/timeparse"
) )
type ObservationService interface { type ObservationService interface {
GetCurrent(ctx context.Context) (observations.CurrentResponse, error) GetRecent(ctx context.Context, count int, unitSystem string) (observations.Response, error)
}
type ConditionsService interface {
GetCurrent(ctx context.Context, unitSystem string) (conditions.Response, error)
} }
type ForecastService interface { type ForecastService interface {
GetByTimestamp(ctx context.Context, ts time.Time) (forecasts.Response, error) GetByTimestamp(ctx context.Context, ts time.Time, unitSystem string) (forecasts.Response, error)
} }
type AlertService interface { type AlertService interface {
@@ -25,32 +34,67 @@ type AlertService interface {
} }
type Server struct { type Server struct {
obsSvc ObservationService obsSvc ObservationService
fcSvc ForecastService conditionsSvc ConditionsService
alertsSvc AlertService fcSvc ForecastService
alertsSvc AlertService
} }
func NewServer(obsSvc ObservationService, fcSvc ForecastService, alertsSvc AlertService) *Server { func NewServer(obsSvc ObservationService, conditionsSvc ConditionsService, fcSvc ForecastService, alertsSvc AlertService) *Server {
return &Server{obsSvc: obsSvc, fcSvc: fcSvc, alertsSvc: alertsSvc} return &Server{obsSvc: obsSvc, conditionsSvc: conditionsSvc, fcSvc: fcSvc, alertsSvc: alertsSvc}
} }
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/observations/current", s.handleObservationsCurrent) mux.HandleFunc("/observations", s.handleObservations)
mux.HandleFunc("/conditions/current", s.handleConditionsCurrent)
mux.HandleFunc("/forecast", s.handleForecast) mux.HandleFunc("/forecast", s.handleForecast)
mux.HandleFunc("/alerts/current", s.handleAlertsCurrent) mux.HandleFunc("/alerts/current", s.handleAlertsCurrent)
return mux return mux
} }
func (s *Server) handleObservationsCurrent(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConditionsCurrent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return return
} }
resp, err := s.obsSvc.GetCurrent(r.Context()) unitSystem, err := parseUnits(r, constants.UnitSystemUS)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch current observations") writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
resp, err := s.conditionsSvc.GetCurrent(r.Context(), unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch current conditions")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleObservations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
unitSystem, err := parseUnits(r, constants.UnitSystemMetric)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
count, err := parseCount(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
resp, err := s.obsSvc.GetRecent(r.Context(), count, unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch observations")
return return
} }
@@ -63,6 +107,12 @@ func (s *Server) handleForecast(w http.ResponseWriter, r *http.Request) {
return return
} }
unitSystem, err := parseUnits(r, constants.UnitSystemUS)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
raw := r.URL.Query().Get("timestamp") raw := r.URL.Query().Get("timestamp")
ts, err := timeparse.ParseTimestamp(raw) ts, err := timeparse.ParseTimestamp(raw)
if err != nil { if err != nil {
@@ -70,7 +120,7 @@ func (s *Server) handleForecast(w http.ResponseWriter, r *http.Request) {
return return
} }
resp, err := s.fcSvc.GetByTimestamp(r.Context(), ts) resp, err := s.fcSvc.GetByTimestamp(r.Context(), ts, unitSystem)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch forecast") writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch forecast")
return return
@@ -94,6 +144,35 @@ func (s *Server) handleAlertsCurrent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }
func parseCount(r *http.Request) (int, error) {
raw := strings.TrimSpace(r.URL.Query().Get("count"))
if raw == "" {
return constants.DefaultObservationCount, nil
}
count, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("count must be an integer between 1 and %d", constants.MaxObservationCount)
}
if count < 1 || count > constants.MaxObservationCount {
return 0, fmt.Errorf("count must be an integer between 1 and %d", constants.MaxObservationCount)
}
return count, nil
}
func parseUnits(r *http.Request, defaultSystem string) (string, error) {
raw := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("units")))
if raw == "" {
return defaultSystem, nil
}
switch raw {
case constants.UnitSystemUS, constants.UnitSystemMetric:
return raw, nil
default:
return "", fmt.Errorf("units must be one of: us, metric")
}
}
type errorResponse struct { type errorResponse struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/conditions"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
@@ -17,27 +18,21 @@ import (
) )
type fakeObservationRepo struct { type fakeObservationRepo struct {
summary ports.ObservationSummaryMetric summary ports.ObservationCurrentConditionsMetric
conditions []ports.ObservationConditionMetric summaryWindow time.Duration
precip []string
summaryWin time.Duration observations []ports.ObservationRecordMetric
conditionsWin time.Duration recentCount int
precipWin time.Duration
} }
func (f *fakeObservationRepo) GetCurrentSummary(_ context.Context, window time.Duration) (ports.ObservationSummaryMetric, error) { func (f *fakeObservationRepo) GetCurrentConditionsSummary(_ context.Context, window time.Duration) (ports.ObservationCurrentConditionsMetric, error) {
f.summaryWin = window f.summaryWindow = window
return f.summary, nil return f.summary, nil
} }
func (f *fakeObservationRepo) ListCurrentConditions(_ context.Context, window time.Duration) ([]ports.ObservationConditionMetric, error) { func (f *fakeObservationRepo) ListRecentObservations(_ context.Context, count int) ([]ports.ObservationRecordMetric, error) {
f.conditionsWin = window f.recentCount = count
return f.conditions, nil return f.observations, nil
}
func (f *fakeObservationRepo) ListCurrentPrecipitationEvents(_ context.Context, window time.Duration) ([]string, error) {
f.precipWin = window
return f.precip, nil
} }
type fakeForecastRepo struct { type fakeForecastRepo struct {
@@ -58,15 +53,349 @@ func (fakeAlertRepo) ListCurrentAlerts(context.Context) ([]ports.AlertRecord, er
return nil, nil return nil, nil
} }
func TestForecastInvalidTimestampReturns400(t *testing.T) { func newTestUnitRegistry(t *testing.T) *units.Registry {
fcRepo := &fakeForecastRepo{} t.Helper()
obsRepo := &fakeObservationRepo{}
server := NewServer( reg := units.NewRegistry()
observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow), if err := reg.Register(units.StaticFactory{UnitSystem: constants.UnitSystemUS, Converter: units.USConverter{}}); err != nil {
forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit), t.Fatalf("register us converter: %v", err)
}
if err := reg.Register(units.StaticFactory{UnitSystem: constants.UnitSystemMetric, Converter: units.MetricConverter{}}); err != nil {
t.Fatalf("register metric converter: %v", err)
}
return reg
}
func newTestHandler(t *testing.T, obsRepo *fakeObservationRepo, fcRepo *fakeForecastRepo) http.Handler {
t.Helper()
reg := newTestUnitRegistry(t)
return NewServer(
observations.NewService(obsRepo, reg),
conditions.NewService(obsRepo, reg, constants.ObservationWindow),
forecasts.NewService(fcRepo, reg, constants.ForecastQueryLimit),
alerts.NewService(fakeAlertRepo{}), alerts.NewService(fakeAlertRepo{}),
).Handler() ).Handler()
}
func TestLegacyObservationsCurrentPathRemoved(t *testing.T) {
server := newTestHandler(t, &fakeObservationRepo{}, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/observations/current", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for removed /observations/current, got %d", w.Code)
}
}
func TestConditionsCurrentDefaultsToUSUnits(t *testing.T) {
tempC := 20.0
appTempC := 15.0
dewpointC := 10.0
relHumidity := 81.0
windKmh := 10.0
windDir := 270.0
conditionCode := 63
isDay := true
obsRepo := &fakeObservationRepo{
summary: ports.ObservationCurrentConditionsMetric{
TemperatureC: &tempC,
ApparentTemperatureC: &appTempC,
DewpointC: &dewpointC,
RelativeHumidity: &relHumidity,
WindSpeedKmh: &windKmh,
WindDirectionDegrees: &windDir,
ConditionCode: &conditionCode,
IsDay: &isDay,
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conditions/current", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := payload["temperatureF"].(float64); got != 68.0 {
t.Fatalf("expected temperatureF=68.0, got %v", got)
}
if got := payload["apparentTemperatureF"].(float64); got != 59.0 {
t.Fatalf("expected apparentTemperatureF=59.0, got %v", got)
}
if got := payload["dewpointF"].(float64); got != 50.0 {
t.Fatalf("expected dewpointF=50.0, got %v", got)
}
if got := payload["windSpeedMph"].(float64); got != 6.2 {
t.Fatalf("expected windSpeedMph=6.2, got %v", got)
}
if got := payload["relativeHumidityPercent"].(float64); got != 81.0 {
t.Fatalf("expected relativeHumidityPercent=81.0, got %v", got)
}
if got := payload["windDirectionDegrees"].(float64); got != 270.0 {
t.Fatalf("expected windDirectionDegrees=270.0, got %v", got)
}
if got := payload["conditionCode"].(float64); got != 63 {
t.Fatalf("expected conditionCode=63, got %v", got)
}
if got := payload["conditionText"].(string); got != "Rain" {
t.Fatalf("expected conditionText=Rain, got %v", got)
}
if got := payload["isDay"].(bool); !got {
t.Fatalf("expected isDay=true, got %v", got)
}
if _, exists := payload["temperatureC"]; exists {
t.Fatalf("did not expect metric key temperatureC in default US response")
}
if obsRepo.summaryWindow != constants.ObservationWindow {
t.Fatalf("expected observation window %s, got %s", constants.ObservationWindow, obsRepo.summaryWindow)
}
}
func TestConditionsCurrentSupportsMetricUnits(t *testing.T) {
tempC := 20.0
windKmh := 10.0
conditionCode := 63
isDay := false
obsRepo := &fakeObservationRepo{
summary: ports.ObservationCurrentConditionsMetric{
TemperatureC: &tempC,
WindSpeedKmh: &windKmh,
ConditionCode: &conditionCode,
IsDay: &isDay,
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conditions/current?units=metric", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := payload["temperatureC"].(float64); got != 20.0 {
t.Fatalf("expected temperatureC=20.0, got %v", got)
}
if got := payload["windSpeedKmh"].(float64); got != 10.0 {
t.Fatalf("expected windSpeedKmh=10.0, got %v", got)
}
if got := payload["conditionCode"].(float64); got != 63 {
t.Fatalf("expected conditionCode=63, got %v", got)
}
if got := payload["conditionText"].(string); got != "Rain" {
t.Fatalf("expected conditionText=Rain, got %v", got)
}
if got := payload["isDay"].(bool); got {
t.Fatalf("expected isDay=false, got %v", got)
}
if _, exists := payload["temperatureF"]; exists {
t.Fatalf("did not expect US key temperatureF in metric response")
}
}
func TestConditionsCurrentOmitsConditionFieldsWhenNoObservations(t *testing.T) {
obsRepo := &fakeObservationRepo{
summary: ports.ObservationCurrentConditionsMetric{},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conditions/current", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if _, exists := payload["conditionCode"]; exists {
t.Fatalf("expected conditionCode omitted when no observations")
}
if _, exists := payload["conditionText"]; exists {
t.Fatalf("expected conditionText omitted when no observations")
}
if _, exists := payload["isDay"]; exists {
t.Fatalf("expected isDay omitted when no observations")
}
}
func TestConditionsCurrentConditionTextUsesNilDayFallback(t *testing.T) {
conditionCode := 0
obsRepo := &fakeObservationRepo{
summary: ports.ObservationCurrentConditionsMetric{
ConditionCode: &conditionCode,
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conditions/current", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := payload["conditionText"].(string); got != "Sunny" {
t.Fatalf("expected conditionText=Sunny, got %v", got)
}
if _, exists := payload["isDay"]; exists {
t.Fatalf("expected isDay omitted when summary isDay is nil")
}
}
func TestObservationsDefaultsToMetricAndDefaultCount(t *testing.T) {
tempC := 10.0
windKmh := 16.0
stationID := "KSTL"
stationName := "St Louis"
textDescription := "Cloudy"
obsRepo := &fakeObservationRepo{
observations: []ports.ObservationRecordMetric{
{
EventID: "evt-1",
StationID: &stationID,
StationName: &stationName,
Timestamp: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
ConditionCode: 3,
TextDescription: &textDescription,
TemperatureC: &tempC,
WindSpeedKmh: &windKmh,
PresentWeather: []ports.ObservationPresentWeatherMetric{{Raw: map[string]any{"wx": "rain"}}},
},
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/observations", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if obsRepo.recentCount != constants.DefaultObservationCount {
t.Fatalf("expected default count %d, got %d", constants.DefaultObservationCount, obsRepo.recentCount)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
observationsList := payload["observations"].([]any)
first := observationsList[0].(map[string]any)
if got := first["temperatureC"].(float64); got != 10.0 {
t.Fatalf("expected temperatureC=10.0, got %v", got)
}
if got := first["windSpeedKmh"].(float64); got != 16.0 {
t.Fatalf("expected windSpeedKmh=16.0, got %v", got)
}
if _, exists := first["temperatureF"]; exists {
t.Fatalf("did not expect US key temperatureF in metric response")
}
if got := first["conditionCode"].(float64); got != 3 {
t.Fatalf("expected conditionCode=3, got %v", got)
}
pw := first["presentWeather"].([]any)
raw := pw[0].(map[string]any)["raw"].(map[string]any)
if got := raw["wx"]; got != "rain" {
t.Fatalf("expected presentWeather raw payload preserved, got %v", got)
}
}
func TestObservationsSupportsUSUnitsAndCountOverride(t *testing.T) {
tempC := 10.0
windKmh := 16.09344
obsRepo := &fakeObservationRepo{
observations: []ports.ObservationRecordMetric{
{
EventID: "evt-1",
Timestamp: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
ConditionCode: 1,
TemperatureC: &tempC,
WindSpeedKmh: &windKmh,
},
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/observations?count=2&units=us", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if obsRepo.recentCount != 2 {
t.Fatalf("expected count override 2, got %d", obsRepo.recentCount)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
first := payload["observations"].([]any)[0].(map[string]any)
if got := first["temperatureF"].(float64); got != 50.0 {
t.Fatalf("expected temperatureF=50.0, got %v", got)
}
if got := first["windSpeedMph"].(float64); got != 10.0 {
t.Fatalf("expected windSpeedMph=10.0, got %v", got)
}
if _, exists := first["temperatureC"]; exists {
t.Fatalf("did not expect metric key temperatureC in US response")
}
}
func TestObservationsCountValidation(t *testing.T) {
server := newTestHandler(t, &fakeObservationRepo{}, &fakeForecastRepo{})
cases := []string{
"/observations?count=0",
"/observations?count=-1",
"/observations?count=abc",
"/observations?count=101",
}
for _, path := range cases {
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for %s, got %d", path, w.Code)
}
}
}
func TestInvalidUnitsReturn400(t *testing.T) {
server := newTestHandler(t, &fakeObservationRepo{}, &fakeForecastRepo{})
cases := []string{
"/conditions/current?units=bad",
"/observations?units=bad",
"/forecast?timestamp=2026-03-17T12:30:00Z&units=bad",
}
for _, path := range cases {
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for %s, got %d", path, w.Code)
}
}
}
func TestForecastInvalidTimestampReturns400(t *testing.T) {
server := newTestHandler(t, &fakeObservationRepo{}, &fakeForecastRepo{})
req := httptest.NewRequest(http.MethodGet, "/forecast?timestamp=bad-time", nil) req := httptest.NewRequest(http.MethodGet, "/forecast?timestamp=bad-time", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -85,85 +414,7 @@ func TestForecastInvalidTimestampReturns400(t *testing.T) {
} }
} }
func TestObservationsCurrentReturnsUSUnits(t *testing.T) { func TestForecastUnitSelection(t *testing.T) {
summaryTemp := 20.0
summaryAppTemp := 15.0
condTemp := 10.0
station := "KSTL"
text := "Cloudy"
providerText := "OVC"
conditionText := "Cloudy"
obsRepo := &fakeObservationRepo{
summary: ports.ObservationSummaryMetric{
TemperatureC: &summaryTemp,
ApparentTemperatureC: &summaryAppTemp,
},
conditions: []ports.ObservationConditionMetric{
{
StationID: &station,
ObservedAt: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
TemperatureC: &condTemp,
TextDescription: &text,
ProviderRawDescription: &providerText,
ConditionText: &conditionText,
},
},
precip: []string{"rain"},
}
fcRepo := &fakeForecastRepo{}
server := NewServer(
observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow),
forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit),
alerts.NewService(fakeAlertRepo{}),
).Handler()
req := httptest.NewRequest(http.MethodGet, "/observations/current", nil)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
summary := payload["summary"].(map[string]any)
if got := summary["temperatureF"].(float64); got != 68.0 {
t.Fatalf("expected summary temperatureF=68.0, got %v", got)
}
if got := summary["apparentTemperatureF"].(float64); got != 59.0 {
t.Fatalf("expected summary apparentTemperatureF=59.0, got %v", got)
}
if _, exists := summary["temperatureC"]; exists {
t.Fatalf("did not expect metric key temperatureC in US response")
}
if _, exists := summary["windowMinutes"]; exists {
t.Fatalf("did not expect deprecated key windowMinutes in observations summary")
}
conditions := payload["conditions"].([]any)
first := conditions[0].(map[string]any)
if got := first["temperatureF"].(float64); got != 50.0 {
t.Fatalf("expected conditions[0].temperatureF=50.0, got %v", got)
}
if _, exists := first["providerRawDescription"]; exists {
t.Fatalf("did not expect deprecated key providerRawDescription in observations condition")
}
if _, exists := first["conditionText"]; exists {
t.Fatalf("did not expect deprecated key conditionText in observations condition")
}
if obsRepo.summaryWin != constants.ObservationWindow || obsRepo.conditionsWin != constants.ObservationWindow || obsRepo.precipWin != constants.ObservationWindow {
t.Fatalf("expected observation window %s to be used", constants.ObservationWindow)
}
}
func TestForecastReturnsUSUnits(t *testing.T) {
tempC := 0.0 tempC := 0.0
tempMinC := -1.0 tempMinC := -1.0
tempMaxC := 1.0 tempMaxC := 1.0
@@ -189,49 +440,48 @@ func TestForecastReturnsUSUnits(t *testing.T) {
}, },
}, },
} }
server := newTestHandler(t, &fakeObservationRepo{}, fcRepo)
obsRepo := &fakeObservationRepo{} wUS := httptest.NewRecorder()
server := NewServer( server.ServeHTTP(wUS, httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z", nil))
observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow), if wUS.Code != http.StatusOK {
forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit), t.Fatalf("expected US forecast 200, got %d", wUS.Code)
alerts.NewService(fakeAlertRepo{}),
).Handler()
req := httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z", nil)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
} }
var payload map[string]any var usPayload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { if err := json.Unmarshal(wUS.Body.Bytes(), &usPayload); err != nil {
t.Fatalf("decode response: %v", err) t.Fatalf("decode us forecast: %v", err)
} }
usPeriod := usPayload["periods"].([]any)[0].(map[string]any)
periods := payload["periods"].([]any) if got := usPeriod["temperatureF"].(float64); got != 32.0 {
first := periods[0].(map[string]any)
if got := first["temperatureF"].(float64); got != 32.0 {
t.Fatalf("expected temperatureF=32.0, got %v", got) t.Fatalf("expected temperatureF=32.0, got %v", got)
} }
if got := first["temperatureFMin"].(float64); got != 30.2 { if got := usPeriod["windSpeedMph"].(float64); got != 6.2 {
t.Fatalf("expected temperatureFMin=30.2, got %v", got)
}
if got := first["temperatureFMax"].(float64); got != 33.8 {
t.Fatalf("expected temperatureFMax=33.8, got %v", got)
}
if got := first["apparentTemperatureF"].(float64); got != 28.4 {
t.Fatalf("expected apparentTemperatureF=28.4, got %v", got)
}
if got := first["windSpeedMph"].(float64); got != 6.2 {
t.Fatalf("expected windSpeedMph=6.2, got %v", got) t.Fatalf("expected windSpeedMph=6.2, got %v", got)
} }
if got := first["windGustMph"].(float64); got != 10.0 { if _, exists := usPeriod["temperatureC"]; exists {
t.Fatalf("expected windGustMph=10.0, got %v", got) t.Fatalf("did not expect metric key temperatureC in US response")
} }
if _, exists := first["windSpeedKmh"]; exists {
t.Fatalf("did not expect metric key windSpeedKmh in US response") wMetric := httptest.NewRecorder()
server.ServeHTTP(wMetric, httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z&units=metric", nil))
if wMetric.Code != http.StatusOK {
t.Fatalf("expected metric forecast 200, got %d", wMetric.Code)
}
var metricPayload map[string]any
if err := json.Unmarshal(wMetric.Body.Bytes(), &metricPayload); err != nil {
t.Fatalf("decode metric forecast: %v", err)
}
metricPeriod := metricPayload["periods"].([]any)[0].(map[string]any)
if got := metricPeriod["temperatureC"].(float64); got != 0.0 {
t.Fatalf("expected temperatureC=0.0, got %v", got)
}
if got := metricPeriod["windSpeedKmh"].(float64); got != 10.0 {
t.Fatalf("expected windSpeedKmh=10.0, got %v", got)
}
if _, exists := metricPeriod["temperatureF"]; exists {
t.Fatalf("did not expect US key temperatureF in metric response")
} }
if fcRepo.gotLimit != constants.ForecastQueryLimit { if fcRepo.gotLimit != constants.ForecastQueryLimit {
@@ -241,73 +491,3 @@ func TestForecastReturnsUSUnits(t *testing.T) {
t.Fatalf("expected forecast timestamp passed to repo") t.Fatalf("expected forecast timestamp passed to repo")
} }
} }
func TestNullFieldsAreOmittedFromJSON(t *testing.T) {
obsRepo := &fakeObservationRepo{
summary: ports.ObservationSummaryMetric{},
conditions: []ports.ObservationConditionMetric{
{
ObservedAt: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
},
},
}
fcRepo := &fakeForecastRepo{
periods: []ports.ForecastPeriodMetric{
{
PeriodIndex: 1,
StartTime: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 3, 17, 13, 0, 0, 0, time.UTC),
ConditionCode: 1,
},
},
}
server := NewServer(
observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow),
forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit),
alerts.NewService(fakeAlertRepo{}),
).Handler()
wObs := httptest.NewRecorder()
server.ServeHTTP(wObs, httptest.NewRequest(http.MethodGet, "/observations/current", nil))
if wObs.Code != http.StatusOK {
t.Fatalf("expected observations 200, got %d", wObs.Code)
}
var obsPayload map[string]any
if err := json.Unmarshal(wObs.Body.Bytes(), &obsPayload); err != nil {
t.Fatalf("decode observations response: %v", err)
}
summary := obsPayload["summary"].(map[string]any)
if _, exists := summary["temperatureF"]; exists {
t.Fatalf("expected summary.temperatureF to be omitted when nil")
}
if _, exists := summary["windowMinutes"]; exists {
t.Fatalf("expected summary.windowMinutes to be omitted")
}
conditions := obsPayload["conditions"].([]any)
firstCond := conditions[0].(map[string]any)
if _, exists := firstCond["temperatureF"]; exists {
t.Fatalf("expected conditions[0].temperatureF to be omitted when nil")
}
wFc := httptest.NewRecorder()
server.ServeHTTP(wFc, httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z", nil))
if wFc.Code != http.StatusOK {
t.Fatalf("expected forecast 200, got %d", wFc.Code)
}
var fcPayload map[string]any
if err := json.Unmarshal(wFc.Body.Bytes(), &fcPayload); err != nil {
t.Fatalf("decode forecast response: %v", err)
}
periods := fcPayload["periods"].([]any)
firstPeriod := periods[0].(map[string]any)
if _, exists := firstPeriod["temperatureF"]; exists {
t.Fatalf("expected periods[0].temperatureF to be omitted when nil")
}
if _, exists := firstPeriod["windSpeedMph"]; exists {
t.Fatalf("expected periods[0].windSpeedMph to be omitted when nil")
}
}

View File

@@ -3,6 +3,7 @@ package postgres
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -18,84 +19,161 @@ func NewObservationRepository(pool *pgxpool.Pool) *ObservationRepository {
return &ObservationRepository{pool: pool} return &ObservationRepository{pool: pool}
} }
func (r *ObservationRepository) GetCurrentSummary(ctx context.Context, window time.Duration) (ports.ObservationSummaryMetric, error) { func (r *ObservationRepository) GetCurrentConditionsSummary(ctx context.Context, window time.Duration) (ports.ObservationCurrentConditionsMetric, error) {
var temperature sql.NullFloat64 var temperature sql.NullFloat64
var apparent sql.NullFloat64 var apparent sql.NullFloat64
var dewpoint sql.NullFloat64
var relativeHumidity sql.NullFloat64
var windSpeed sql.NullFloat64
var windDirection sql.NullFloat64
var conditionCode sql.NullInt64
var isDay sql.NullBool
if err := r.pool.QueryRow(ctx, queryObservationSummary, windowMinutes(window)).Scan(&temperature, &apparent); err != nil { if err := r.pool.QueryRow(ctx, queryCurrentConditionsSummary, windowMinutes(window)).Scan(
return ports.ObservationSummaryMetric{}, fmt.Errorf("query observation summary: %w", err) &temperature,
&apparent,
&dewpoint,
&relativeHumidity,
&windSpeed,
&windDirection,
&conditionCode,
&isDay,
); err != nil {
return ports.ObservationCurrentConditionsMetric{}, fmt.Errorf("query current conditions summary: %w", err)
} }
return ports.ObservationSummaryMetric{ return ports.ObservationCurrentConditionsMetric{
TemperatureC: ptrFloat64(temperature), TemperatureC: ptrFloat64(temperature),
ApparentTemperatureC: ptrFloat64(apparent), ApparentTemperatureC: ptrFloat64(apparent),
DewpointC: ptrFloat64(dewpoint),
RelativeHumidity: ptrFloat64(relativeHumidity),
WindSpeedKmh: ptrFloat64(windSpeed),
WindDirectionDegrees: ptrFloat64(windDirection),
ConditionCode: ptrInt(conditionCode),
IsDay: ptrBool(isDay),
}, nil }, nil
} }
func (r *ObservationRepository) ListCurrentConditions(ctx context.Context, window time.Duration) ([]ports.ObservationConditionMetric, error) { func (r *ObservationRepository) ListRecentObservations(ctx context.Context, count int) ([]ports.ObservationRecordMetric, error) {
rows, err := r.pool.Query(ctx, queryObservationConditions, windowMinutes(window)) rows, err := r.pool.Query(ctx, queryRecentObservations, count)
if err != nil { if err != nil {
return nil, fmt.Errorf("query observation conditions: %w", err) return nil, fmt.Errorf("query recent observations: %w", err)
} }
defer rows.Close() defer rows.Close()
out := make([]ports.ObservationConditionMetric, 0) out := make([]ports.ObservationRecordMetric, 0, count)
eventIDs := make([]string, 0, count)
for rows.Next() { for rows.Next() {
var eventID string
var stationID sql.NullString var stationID sql.NullString
var observedAt time.Time var stationName sql.NullString
var temperature sql.NullFloat64 var timestamp time.Time
var conditionCode int
var isDay sql.NullBool
var textDescription sql.NullString var textDescription sql.NullString
var providerRawDescription sql.NullString var temperature sql.NullFloat64
var conditionText sql.NullString var dewpoint sql.NullFloat64
var windDirection sql.NullFloat64
var windSpeed sql.NullFloat64
var windGust sql.NullFloat64
var pressure sql.NullFloat64
var visibility sql.NullFloat64
var relativeHumidity sql.NullFloat64
var apparent sql.NullFloat64
if err := rows.Scan( if err := rows.Scan(
&eventID,
&stationID, &stationID,
&observedAt, &stationName,
&temperature, &timestamp,
&conditionCode,
&isDay,
&textDescription, &textDescription,
&providerRawDescription, &temperature,
&conditionText, &dewpoint,
&windDirection,
&windSpeed,
&windGust,
&pressure,
&visibility,
&relativeHumidity,
&apparent,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan observation condition row: %w", err) return nil, fmt.Errorf("scan recent observation row: %w", err)
} }
out = append(out, ports.ObservationConditionMetric{ eventIDs = append(eventIDs, eventID)
StationID: ptrString(stationID), out = append(out, ports.ObservationRecordMetric{
ObservedAt: observedAt, EventID: eventID,
TemperatureC: ptrFloat64(temperature), StationID: ptrString(stationID),
TextDescription: ptrString(textDescription), StationName: ptrString(stationName),
ProviderRawDescription: ptrString(providerRawDescription), Timestamp: timestamp,
ConditionText: ptrString(conditionText), ConditionCode: conditionCode,
IsDay: ptrBool(isDay),
TextDescription: ptrString(textDescription),
TemperatureC: ptrFloat64(temperature),
DewpointC: ptrFloat64(dewpoint),
WindDirectionDegrees: ptrFloat64(windDirection),
WindSpeedKmh: ptrFloat64(windSpeed),
WindGustKmh: ptrFloat64(windGust),
BarometricPressurePa: ptrFloat64(pressure),
VisibilityMeters: ptrFloat64(visibility),
RelativeHumidityPercent: ptrFloat64(relativeHumidity),
ApparentTemperatureC: ptrFloat64(apparent),
}) })
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation conditions rows: %w", err) return nil, fmt.Errorf("iterate recent observations rows: %w", err)
}
if len(out) == 0 {
return out, nil
}
presentWeatherByEventID, err := r.listObservationPresentWeather(ctx, eventIDs)
if err != nil {
return nil, err
}
for i := range out {
out[i].PresentWeather = presentWeatherByEventID[out[i].EventID]
} }
return out, nil return out, nil
} }
func (r *ObservationRepository) ListCurrentPrecipitationEvents(ctx context.Context, window time.Duration) ([]string, error) { func (r *ObservationRepository) listObservationPresentWeather(ctx context.Context, eventIDs []string) (map[string][]ports.ObservationPresentWeatherMetric, error) {
rows, err := r.pool.Query(ctx, queryObservationPrecipitation, windowMinutes(window)) rows, err := r.pool.Query(ctx, queryObservationPresentWeather, eventIDs)
if err != nil { if err != nil {
return nil, fmt.Errorf("query observation precipitation events: %w", err) return nil, fmt.Errorf("query observation present weather: %w", err)
} }
defer rows.Close() defer rows.Close()
out := make([]string, 0) out := make(map[string][]ports.ObservationPresentWeatherMetric, len(eventIDs))
for rows.Next() { for rows.Next() {
var eventID string
var weatherIndex int
var rawText sql.NullString var rawText sql.NullString
if err := rows.Scan(&rawText); err != nil {
return nil, fmt.Errorf("scan observation precipitation row: %w", err) if err := rows.Scan(&eventID, &weatherIndex, &rawText); err != nil {
return nil, fmt.Errorf("scan observation present weather row: %w", err)
} }
var raw map[string]any
if rawText.Valid { if rawText.Valid {
out = append(out, rawText.String) if err := json.Unmarshal([]byte(rawText.String), &raw); err != nil {
return nil, fmt.Errorf("decode observation present weather raw payload: %w", err)
}
} }
out[eventID] = append(out[eventID], ports.ObservationPresentWeatherMetric{
Raw: raw,
})
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation precipitation rows: %w", err) return nil, fmt.Errorf("iterate observation present weather rows: %w", err)
} }
return out, nil return out, nil

View File

@@ -1,33 +1,80 @@
package postgres package postgres
const ( const (
queryObservationSummary = ` queryCurrentConditionsSummary = `
WITH windowed AS (
SELECT
temperature_c,
apparent_temperature_c,
dewpoint_c,
relative_humidity_percent,
wind_speed_kmh,
wind_direction_degrees,
condition_code,
is_day,
observed_at
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
)
SELECT SELECT
AVG(temperature_c) AS temperature_c, AVG(temperature_c) AS temperature_c,
AVG(apparent_temperature_c) AS apparent_temperature_c AVG(apparent_temperature_c) AS apparent_temperature_c,
FROM observations AVG(dewpoint_c) AS dewpoint_c,
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1); AVG(relative_humidity_percent) AS relative_humidity_percent,
AVG(wind_speed_kmh) AS wind_speed_kmh,
CASE
WHEN COUNT(wind_direction_degrees) FILTER (WHERE wind_direction_degrees IS NOT NULL) = 0 THEN NULL
ELSE MOD(
DEGREES(
ATAN2(
AVG(SIN(RADIANS(wind_direction_degrees))),
AVG(COS(RADIANS(wind_direction_degrees)))
)
) + 360.0,
360.0
)
END AS wind_direction_degrees,
MAX(condition_code) AS condition_code,
(
SELECT is_day
FROM windowed
ORDER BY observed_at DESC
LIMIT 1
) AS is_day
FROM windowed;
` `
queryObservationConditions = ` queryRecentObservations = `
SELECT SELECT
event_id,
station_id, station_id,
station_name,
observed_at, observed_at,
temperature_c, condition_code,
is_day,
text_description, text_description,
provider_raw_description, temperature_c,
condition_text dewpoint_c,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
relative_humidity_percent,
apparent_temperature_c
FROM observations FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1) ORDER BY observed_at DESC
ORDER BY observed_at DESC; LIMIT $1;
` `
queryObservationPrecipitation = ` queryObservationPresentWeather = `
SELECT SELECT
event_id,
weather_index,
raw_text raw_text
FROM observation_present_weather FROM observation_present_weather
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1) WHERE event_id = ANY($1)
ORDER BY observed_at DESC; ORDER BY event_id ASC, weather_index ASC;
` `
queryForecastPeriodsAt = ` queryForecastPeriodsAt = `

View File

@@ -8,18 +8,31 @@ import (
) )
func TestQueriesUseParametersNotHardcodedValues(t *testing.T) { func TestQueriesUseParametersNotHardcodedValues(t *testing.T) {
if !strings.Contains(queryObservationSummary, "make_interval(mins => $1)") { if !strings.Contains(queryCurrentConditionsSummary, "make_interval(mins => $1)") {
t.Fatalf("observation summary query must use $1 minutes parameter") t.Fatalf("current conditions summary query must use $1 minutes parameter")
} }
if strings.Contains(queryObservationSummary, "30 minutes") { if strings.Contains(queryCurrentConditionsSummary, "30 minutes") {
t.Fatalf("observation summary query should not hardcode 30 minutes") t.Fatalf("current conditions summary query should not hardcode 30 minutes")
}
if !strings.Contains(queryCurrentConditionsSummary, "ATAN2(") {
t.Fatalf("current conditions summary query should compute circular mean for wind direction")
}
if !strings.Contains(queryCurrentConditionsSummary, "MAX(condition_code)") {
t.Fatalf("current conditions summary query should compute max condition code")
}
if !strings.Contains(queryCurrentConditionsSummary, "ORDER BY observed_at DESC") || !strings.Contains(queryCurrentConditionsSummary, "LIMIT 1") {
t.Fatalf("current conditions summary query should select latest is_day")
} }
if !strings.Contains(queryObservationConditions, "make_interval(mins => $1)") { if !strings.Contains(queryRecentObservations, "LIMIT $1") {
t.Fatalf("observation conditions query must use $1 minutes parameter") t.Fatalf("recent observations query must use parameterized limit")
} }
if !strings.Contains(queryObservationPrecipitation, "make_interval(mins => $1)") { if strings.Contains(queryRecentObservations, "LIMIT 5") {
t.Fatalf("observation precipitation query must use $1 minutes parameter") t.Fatalf("recent observations query should not hardcode limit=5")
}
if !strings.Contains(queryObservationPresentWeather, "event_id = ANY($1)") {
t.Fatalf("observation present weather query must use parameterized event list")
} }
if !strings.Contains(queryForecastPeriodsAt, "LIMIT $2") { if !strings.Contains(queryForecastPeriodsAt, "LIMIT $2") {

View File

@@ -29,6 +29,14 @@ func ptrBool(v sql.NullBool) *bool {
return &b return &b
} }
func ptrInt(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
i := int(v.Int64)
return &i
}
func ptrTime(v sql.NullTime) *time.Time { func ptrTime(v sql.NullTime) *time.Time {
if !v.Valid { if !v.Valid {
return nil return nil

View File

@@ -0,0 +1,84 @@
package conditions
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
)
type Service struct {
repo ports.ObservationRepository
registry *units.Registry
window time.Duration
}
func NewService(repo ports.ObservationRepository, registry *units.Registry, window time.Duration) *Service {
return &Service{repo: repo, registry: registry, window: window}
}
type Response struct {
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"`
RelativeHumidity *float64 `json:"relativeHumidityPercent,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
ConditionCode *int `json:"conditionCode,omitempty"`
ConditionText *string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
}
func (s *Service) GetCurrent(ctx context.Context, unitSystem string) (Response, error) {
if s == nil {
return Response{}, fmt.Errorf("conditions service is nil")
}
converter, err := s.registry.Resolve(unitSystem)
if err != nil {
return Response{}, err
}
summary, err := s.repo.GetCurrentConditionsSummary(ctx, s.window)
if err != nil {
return Response{}, err
}
resp := Response{
RelativeHumidity: summary.RelativeHumidity,
WindDirectionDegrees: summary.WindDirectionDegrees,
ConditionCode: summary.ConditionCode,
IsDay: summary.IsDay,
}
if summary.ConditionCode != nil {
resp.ConditionText = ptrString(standards.WMOText(model.WMOCode(*summary.ConditionCode), summary.IsDay))
}
switch converter.System() {
case constants.UnitSystemUS:
resp.TemperatureF = converter.TemperatureCToOutput(summary.TemperatureC)
resp.ApparentTemperatureF = converter.TemperatureCToOutput(summary.ApparentTemperatureC)
resp.DewpointF = converter.TemperatureCToOutput(summary.DewpointC)
resp.WindSpeedMph = converter.SpeedKmhToOutput(summary.WindSpeedKmh)
default:
resp.TemperatureC = converter.TemperatureCToOutput(summary.TemperatureC)
resp.ApparentTemperatureC = converter.TemperatureCToOutput(summary.ApparentTemperatureC)
resp.DewpointC = converter.TemperatureCToOutput(summary.DewpointC)
resp.WindSpeedKmh = converter.SpeedKmhToOutput(summary.WindSpeedKmh)
}
return resp, nil
}
func ptrString(v string) *string {
return &v
}

View File

@@ -7,16 +7,17 @@ import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports" "gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
) )
type Service struct { type Service struct {
repo ports.ForecastRepository repo ports.ForecastRepository
converter units.Converter registry *units.Registry
limit int limit int
} }
func NewService(repo ports.ForecastRepository, converter units.Converter, limit int) *Service { func NewService(repo ports.ForecastRepository, registry *units.Registry, limit int) *Service {
return &Service{repo: repo, converter: converter, limit: limit} return &Service{repo: repo, registry: registry, limit: limit}
} }
type Response struct { type Response struct {
@@ -36,16 +37,23 @@ type PeriodResponse struct {
TextDescription *string `json:"textDescription,omitempty"` TextDescription *string `json:"textDescription,omitempty"`
DetailedText *string `json:"detailedText,omitempty"` DetailedText *string `json:"detailedText,omitempty"`
IconURL *string `json:"iconUrl,omitempty"` IconURL *string `json:"iconUrl,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"` TemperatureF *float64 `json:"temperatureF,omitempty"`
TemperatureCMin *float64 `json:"temperatureCMin,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty"` TemperatureFMin *float64 `json:"temperatureFMin,omitempty"`
TemperatureCMax *float64 `json:"temperatureCMax,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty"` TemperatureFMax *float64 `json:"temperatureFMax,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"` DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"` RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"` WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"` WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"` WindGustMph *float64 `json:"windGustMph,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"` BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"` VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"` ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"` CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"` ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"`
@@ -54,11 +62,16 @@ type PeriodResponse struct {
UVIndex *float64 `json:"uvIndex,omitempty"` UVIndex *float64 `json:"uvIndex,omitempty"`
} }
func (s *Service) GetByTimestamp(ctx context.Context, ts time.Time) (Response, error) { func (s *Service) GetByTimestamp(ctx context.Context, ts time.Time, unitSystem string) (Response, error) {
if s == nil { if s == nil {
return Response{}, fmt.Errorf("forecasts service is nil") return Response{}, fmt.Errorf("forecasts service is nil")
} }
converter, err := s.registry.Resolve(unitSystem)
if err != nil {
return Response{}, err
}
periodsMetric, err := s.repo.ListForecastPeriodsAt(ctx, ts, s.limit) periodsMetric, err := s.repo.ListForecastPeriodsAt(ctx, ts, s.limit)
if err != nil { if err != nil {
return Response{}, err return Response{}, err
@@ -66,7 +79,7 @@ func (s *Service) GetByTimestamp(ctx context.Context, ts time.Time) (Response, e
periods := make([]PeriodResponse, 0, len(periodsMetric)) periods := make([]PeriodResponse, 0, len(periodsMetric))
for _, p := range periodsMetric { for _, p := range periodsMetric {
periods = append(periods, PeriodResponse{ item := PeriodResponse{
PeriodIndex: p.PeriodIndex, PeriodIndex: p.PeriodIndex,
StartTime: p.StartTime, StartTime: p.StartTime,
EndTime: p.EndTime, EndTime: p.EndTime,
@@ -78,23 +91,37 @@ func (s *Service) GetByTimestamp(ctx context.Context, ts time.Time) (Response, e
TextDescription: p.TextDescription, TextDescription: p.TextDescription,
DetailedText: p.DetailedText, DetailedText: p.DetailedText,
IconURL: p.IconURL, IconURL: p.IconURL,
TemperatureF: s.converter.TemperatureCToOutput(p.TemperatureC),
TemperatureFMin: s.converter.TemperatureCToOutput(p.TemperatureCMin),
TemperatureFMax: s.converter.TemperatureCToOutput(p.TemperatureCMax),
DewpointF: s.converter.TemperatureCToOutput(p.DewpointC),
RelativeHumidityPercent: p.RelativeHumidityPercent, RelativeHumidityPercent: p.RelativeHumidityPercent,
WindDirectionDegrees: p.WindDirectionDegrees, WindDirectionDegrees: p.WindDirectionDegrees,
WindSpeedMph: s.converter.SpeedKmhToOutput(p.WindSpeedKmh),
WindGustMph: s.converter.SpeedKmhToOutput(p.WindGustKmh),
BarometricPressurePa: p.BarometricPressurePa, BarometricPressurePa: p.BarometricPressurePa,
VisibilityMeters: p.VisibilityMeters, VisibilityMeters: p.VisibilityMeters,
ApparentTemperatureF: s.converter.TemperatureCToOutput(p.ApparentTemperatureC),
CloudCoverPercent: p.CloudCoverPercent, CloudCoverPercent: p.CloudCoverPercent,
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitationPercent, ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitationPercent,
PrecipitationAmountMm: p.PrecipitationAmountMm, PrecipitationAmountMm: p.PrecipitationAmountMm,
SnowfallDepthMm: p.SnowfallDepthMm, SnowfallDepthMm: p.SnowfallDepthMm,
UVIndex: p.UVIndex, UVIndex: p.UVIndex,
}) }
switch converter.System() {
case constants.UnitSystemUS:
item.TemperatureF = converter.TemperatureCToOutput(p.TemperatureC)
item.TemperatureFMin = converter.TemperatureCToOutput(p.TemperatureCMin)
item.TemperatureFMax = converter.TemperatureCToOutput(p.TemperatureCMax)
item.DewpointF = converter.TemperatureCToOutput(p.DewpointC)
item.WindSpeedMph = converter.SpeedKmhToOutput(p.WindSpeedKmh)
item.WindGustMph = converter.SpeedKmhToOutput(p.WindGustKmh)
item.ApparentTemperatureF = converter.TemperatureCToOutput(p.ApparentTemperatureC)
default:
item.TemperatureC = converter.TemperatureCToOutput(p.TemperatureC)
item.TemperatureCMin = converter.TemperatureCToOutput(p.TemperatureCMin)
item.TemperatureCMax = converter.TemperatureCToOutput(p.TemperatureCMax)
item.DewpointC = converter.TemperatureCToOutput(p.DewpointC)
item.WindSpeedKmh = converter.SpeedKmhToOutput(p.WindSpeedKmh)
item.WindGustKmh = converter.SpeedKmhToOutput(p.WindGustKmh)
item.ApparentTemperatureC = converter.TemperatureCToOutput(p.ApparentTemperatureC)
}
periods = append(periods, item)
} }
return Response{ return Response{

View File

@@ -7,72 +7,103 @@ import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports" "gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
) )
type Service struct { type Service struct {
repo ports.ObservationRepository repo ports.ObservationRepository
converter units.Converter registry *units.Registry
window time.Duration
} }
func NewService(repo ports.ObservationRepository, converter units.Converter, window time.Duration) *Service { func NewService(repo ports.ObservationRepository, registry *units.Registry) *Service {
return &Service{repo: repo, converter: converter, window: window} return &Service{repo: repo, registry: registry}
} }
type CurrentResponse struct { type Response struct {
Summary SummaryResponse `json:"summary"` Observations []ObservationResponse `json:"observations"`
Conditions []ConditionResponse `json:"conditions"`
PrecipitationEvents []string `json:"precipitationEvents"`
} }
type SummaryResponse struct { type ObservationResponse struct {
TemperatureF *float64 `json:"temperatureF,omitempty"` StationID *string `json:"stationId,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"` StationName *string `json:"stationName,omitempty"`
Timestamp time.Time `json:"timestamp"`
ConditionCode int `json:"conditionCode"`
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"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
PresentWeather []PresentWeatherResponse `json:"presentWeather,omitempty"`
} }
type ConditionResponse struct { type PresentWeatherResponse struct {
StationID *string `json:"stationId,omitempty"` Raw map[string]any `json:"raw,omitempty"`
ObservedAt time.Time `json:"observedAt"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
TextDescription *string `json:"textDescription,omitempty"`
} }
func (s *Service) GetCurrent(ctx context.Context) (CurrentResponse, error) { func (s *Service) GetRecent(ctx context.Context, count int, unitSystem string) (Response, error) {
if s == nil { if s == nil {
return CurrentResponse{}, fmt.Errorf("observations service is nil") return Response{}, fmt.Errorf("observations service is nil")
} }
summary, err := s.repo.GetCurrentSummary(ctx, s.window) converter, err := s.registry.Resolve(unitSystem)
if err != nil { if err != nil {
return CurrentResponse{}, err return Response{}, err
} }
conditionsMetric, err := s.repo.ListCurrentConditions(ctx, s.window) records, err := s.repo.ListRecentObservations(ctx, count)
if err != nil { if err != nil {
return CurrentResponse{}, err return Response{}, err
} }
precip, err := s.repo.ListCurrentPrecipitationEvents(ctx, s.window) out := make([]ObservationResponse, 0, len(records))
if err != nil { for _, r := range records {
return CurrentResponse{}, err item := ObservationResponse{
StationID: r.StationID,
StationName: r.StationName,
Timestamp: r.Timestamp,
ConditionCode: r.ConditionCode,
IsDay: r.IsDay,
TextDescription: r.TextDescription,
WindDirectionDegrees: r.WindDirectionDegrees,
BarometricPressurePa: r.BarometricPressurePa,
VisibilityMeters: r.VisibilityMeters,
RelativeHumidityPercent: r.RelativeHumidityPercent,
}
presentWeather := make([]PresentWeatherResponse, 0, len(r.PresentWeather))
for _, pw := range r.PresentWeather {
presentWeather = append(presentWeather, PresentWeatherResponse{Raw: pw.Raw})
}
item.PresentWeather = presentWeather
switch converter.System() {
case constants.UnitSystemUS:
item.TemperatureF = converter.TemperatureCToOutput(r.TemperatureC)
item.DewpointF = converter.TemperatureCToOutput(r.DewpointC)
item.WindSpeedMph = converter.SpeedKmhToOutput(r.WindSpeedKmh)
item.WindGustMph = converter.SpeedKmhToOutput(r.WindGustKmh)
item.ApparentTemperatureF = converter.TemperatureCToOutput(r.ApparentTemperatureC)
default:
item.TemperatureC = converter.TemperatureCToOutput(r.TemperatureC)
item.DewpointC = converter.TemperatureCToOutput(r.DewpointC)
item.WindSpeedKmh = converter.SpeedKmhToOutput(r.WindSpeedKmh)
item.WindGustKmh = converter.SpeedKmhToOutput(r.WindGustKmh)
item.ApparentTemperatureC = converter.TemperatureCToOutput(r.ApparentTemperatureC)
}
out = append(out, item)
} }
conditions := make([]ConditionResponse, 0, len(conditionsMetric)) return Response{Observations: out}, nil
for _, c := range conditionsMetric {
conditions = append(conditions, ConditionResponse{
StationID: c.StationID,
ObservedAt: c.ObservedAt,
TemperatureF: s.converter.TemperatureCToOutput(c.TemperatureC),
TextDescription: c.TextDescription,
})
}
return CurrentResponse{
Summary: SummaryResponse{
TemperatureF: s.converter.TemperatureCToOutput(summary.TemperatureC),
ApparentTemperatureF: s.converter.TemperatureCToOutput(summary.ApparentTemperatureC),
},
Conditions: conditions,
PrecipitationEvents: precip,
}, nil
} }

View File

@@ -9,10 +9,15 @@ import (
type Converter interface { type Converter interface {
TemperatureCToOutput(celsius *float64) *float64 TemperatureCToOutput(celsius *float64) *float64
SpeedKmhToOutput(kmh *float64) *float64 SpeedKmhToOutput(kmh *float64) *float64
System() string
} }
type USConverter struct{} type USConverter struct{}
func (USConverter) System() string {
return "us"
}
func (USConverter) TemperatureCToOutput(celsius *float64) *float64 { func (USConverter) TemperatureCToOutput(celsius *float64) *float64 {
if celsius == nil { if celsius == nil {
return nil return nil
@@ -29,6 +34,28 @@ func (USConverter) SpeedKmhToOutput(kmh *float64) *float64 {
return &mph return &mph
} }
type MetricConverter struct{}
func (MetricConverter) System() string {
return "metric"
}
func (MetricConverter) TemperatureCToOutput(celsius *float64) *float64 {
return clone(celsius)
}
func (MetricConverter) SpeedKmhToOutput(kmh *float64) *float64 {
return clone(kmh)
}
func clone(v *float64) *float64 {
if v == nil {
return nil
}
out := *v
return &out
}
func round(v float64, precision int) float64 { func round(v float64, precision int) float64 {
pow := math.Pow(10, float64(precision)) pow := math.Pow(10, float64(precision))
return math.Round(v*pow) / pow return math.Round(v*pow) / pow

View File

@@ -41,3 +41,32 @@ func TestUSConverterSpeedKmhToOutput(t *testing.T) {
t.Fatalf("expected 10.0 mph, got %v", got) t.Fatalf("expected 10.0 mph, got %v", got)
} }
} }
func TestMetricConverterPassthrough(t *testing.T) {
c := MetricConverter{}
if got := c.TemperatureCToOutput(nil); got != nil {
t.Fatalf("expected nil for nil temperature input")
}
if got := c.SpeedKmhToOutput(nil); got != nil {
t.Fatalf("expected nil for nil speed input")
}
temp := 12.3456
gotTemp := c.TemperatureCToOutput(&temp)
if gotTemp == nil || *gotTemp != temp {
t.Fatalf("expected temperature passthrough %v, got %v", temp, gotTemp)
}
if gotTemp == &temp {
t.Fatalf("expected temperature output to be a clone pointer")
}
speed := 14.2
gotSpeed := c.SpeedKmhToOutput(&speed)
if gotSpeed == nil || *gotSpeed != speed {
t.Fatalf("expected speed passthrough %v, got %v", speed, gotSpeed)
}
if gotSpeed == &speed {
t.Fatalf("expected speed output to be a clone pointer")
}
}

View File

@@ -0,0 +1,73 @@
package units
import (
"fmt"
"strings"
)
type Factory interface {
System() string
New() Converter
}
type StaticFactory struct {
UnitSystem string
Converter Converter
}
func (f StaticFactory) System() string {
return f.UnitSystem
}
func (f StaticFactory) New() Converter {
return f.Converter
}
type Registry struct {
factories map[string]Factory
}
func NewRegistry() *Registry {
return &Registry{factories: map[string]Factory{}}
}
func (r *Registry) Register(factory Factory) error {
if r == nil {
return fmt.Errorf("register unit factory: registry is nil")
}
if factory == nil {
return fmt.Errorf("register unit factory: factory is nil")
}
system := normalize(factory.System())
if system == "" {
return fmt.Errorf("register unit factory: unit system is empty")
}
if _, exists := r.factories[system]; exists {
return fmt.Errorf("register unit factory: system %q already registered", system)
}
if factory.New() == nil {
return fmt.Errorf("register unit factory: factory returned nil converter for %q", system)
}
r.factories[system] = factory
return nil
}
func (r *Registry) Resolve(system string) (Converter, error) {
if r == nil {
return nil, fmt.Errorf("resolve unit converter: registry is nil")
}
key := normalize(system)
factory, ok := r.factories[key]
if !ok {
return nil, fmt.Errorf("resolve unit converter: unsupported unit system %q", system)
}
converter := factory.New()
if converter == nil {
return nil, fmt.Errorf("resolve unit converter: factory for %q returned nil converter", key)
}
return converter, nil
}
func normalize(system string) string {
return strings.ToLower(strings.TrimSpace(system))
}

View File

@@ -0,0 +1,62 @@
package units
import "testing"
func TestRegistryResolve(t *testing.T) {
reg := NewRegistry()
if err := reg.Register(StaticFactory{UnitSystem: "us", Converter: USConverter{}}); err != nil {
t.Fatalf("register us: %v", err)
}
if err := reg.Register(StaticFactory{UnitSystem: "metric", Converter: MetricConverter{}}); err != nil {
t.Fatalf("register metric: %v", err)
}
converter, err := reg.Resolve("US")
if err != nil {
t.Fatalf("resolve us: %v", err)
}
if converter.System() != "us" {
t.Fatalf("expected us converter, got %q", converter.System())
}
converter, err = reg.Resolve(" metric ")
if err != nil {
t.Fatalf("resolve metric: %v", err)
}
if converter.System() != "metric" {
t.Fatalf("expected metric converter, got %q", converter.System())
}
}
func TestRegistryRejectsInvalidRegistration(t *testing.T) {
reg := NewRegistry()
if err := reg.Register(nil); err == nil {
t.Fatalf("expected error for nil factory")
}
if err := reg.Register(StaticFactory{UnitSystem: "", Converter: USConverter{}}); err == nil {
t.Fatalf("expected error for empty unit system")
}
if err := reg.Register(StaticFactory{UnitSystem: "us", Converter: nil}); err == nil {
t.Fatalf("expected error for nil converter")
}
if err := reg.Register(StaticFactory{UnitSystem: "us", Converter: USConverter{}}); err != nil {
t.Fatalf("register us: %v", err)
}
if err := reg.Register(StaticFactory{UnitSystem: "US", Converter: USConverter{}}); err == nil {
t.Fatalf("expected duplicate registration error")
}
}
func TestRegistryResolveUnknown(t *testing.T) {
reg := NewRegistry()
if err := reg.Register(StaticFactory{UnitSystem: "us", Converter: USConverter{}}); err != nil {
t.Fatalf("register us: %v", err)
}
if _, err := reg.Resolve("metric"); err == nil {
t.Fatalf("expected unsupported unit system error")
}
}

View File

@@ -5,18 +5,39 @@ import (
"time" "time"
) )
type ObservationSummaryMetric struct { type ObservationCurrentConditionsMetric struct {
TemperatureC *float64 TemperatureC *float64
ApparentTemperatureC *float64 ApparentTemperatureC *float64
DewpointC *float64
RelativeHumidity *float64
WindSpeedKmh *float64
WindDirectionDegrees *float64
ConditionCode *int
IsDay *bool
} }
type ObservationConditionMetric struct { type ObservationPresentWeatherMetric struct {
StationID *string Raw map[string]any
ObservedAt time.Time }
TemperatureC *float64
TextDescription *string type ObservationRecordMetric struct {
ProviderRawDescription *string EventID string
ConditionText *string StationID *string
StationName *string
Timestamp time.Time
ConditionCode int
IsDay *bool
TextDescription *string
TemperatureC *float64
DewpointC *float64
WindDirectionDegrees *float64
WindSpeedKmh *float64
WindGustKmh *float64
BarometricPressurePa *float64
VisibilityMeters *float64
RelativeHumidityPercent *float64
ApparentTemperatureC *float64
PresentWeather []ObservationPresentWeatherMetric
} }
type ForecastPeriodMetric struct { type ForecastPeriodMetric struct {
@@ -60,9 +81,8 @@ type AlertRecord struct {
} }
type ObservationRepository interface { type ObservationRepository interface {
GetCurrentSummary(ctx context.Context, window time.Duration) (ObservationSummaryMetric, error) GetCurrentConditionsSummary(ctx context.Context, window time.Duration) (ObservationCurrentConditionsMetric, error)
ListCurrentConditions(ctx context.Context, window time.Duration) ([]ObservationConditionMetric, error) ListRecentObservations(ctx context.Context, count int) ([]ObservationRecordMetric, error)
ListCurrentPrecipitationEvents(ctx context.Context, window time.Duration) ([]string, error)
} }
type ForecastRepository interface { type ForecastRepository interface {

View File

@@ -6,11 +6,23 @@ const (
// ObservationWindow defines how far back observations are queried. // ObservationWindow defines how far back observations are queried.
ObservationWindow = 30 * time.Minute ObservationWindow = 30 * time.Minute
// DefaultObservationCount defines the default number of observations returned.
DefaultObservationCount = 5
// MaxObservationCount defines the max observation count accepted from query params.
MaxObservationCount = 100
// ForecastQueryLimit defines the max forecast periods returned. // ForecastQueryLimit defines the max forecast periods returned.
ForecastQueryLimit = 5 ForecastQueryLimit = 5
// DefaultOutputUnitSystem is the API's default output unit system. // UnitSystemUS identifies the US customary unit system.
DefaultOutputUnitSystem = "us" UnitSystemUS = "us"
// UnitSystemMetric identifies the metric unit system.
UnitSystemMetric = "metric"
// DefaultOutputUnitSystem is the API's default output unit system for forecast/current conditions.
DefaultOutputUnitSystem = UnitSystemUS
// DefaultHTTPAddr is the default listen address for the HTTP server. // DefaultHTTPAddr is the default listen address for the HTTP server.
DefaultHTTPAddr = ":8080" DefaultHTTPAddr = ":8080"