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
- `GET /observations/current`
- `GET /conditions/current`
- `GET /observations?count=<1..100>&units=<us|metric>`
- `GET /forecast?timestamp=<RFC3339 timestamp>`
- `GET /alerts/current`
By default, API output is US units for temperature and wind speed:
- temperature fields: Fahrenheit (`*F`)
- wind fields: mph (`*Mph`)
Default unit behavior:
- `/conditions/current`: US units
- `/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

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/httpapi"
adapterpg "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/postgres"
"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/observations"
"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)
}
converter, err := newDefaultConverter()
unitRegistry, err := newUnitRegistry()
if err != nil {
log.Fatalf("configure units: %v", err)
}
@@ -60,18 +61,19 @@ func main() {
fcRepo := adapterpg.NewForecastRepository(pgDataSource.Pool())
alertRepo := adapterpg.NewAlertRepository(pgDataSource.Pool())
obsSvc := observations.NewService(obsRepo, converter, constants.ObservationWindow)
fcSvc := forecasts.NewService(fcRepo, converter, constants.ForecastQueryLimit)
obsSvc := observations.NewService(obsRepo, unitRegistry)
conditionsSvc := conditions.NewService(obsRepo, unitRegistry, constants.ObservationWindow)
fcSvc := forecasts.NewService(fcRepo, unitRegistry, constants.ForecastQueryLimit)
alertsSvc := alerts.NewService(alertRepo)
server := httpapi.NewServer(obsSvc, fcSvc, alertsSvc)
server := httpapi.NewServer(obsSvc, conditionsSvc, fcSvc, alertsSvc)
httpServer := &http.Server{
Addr: *addr,
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 {
log.Fatalf("http server: %v", err)
}
@@ -92,11 +94,19 @@ func chooseDatabase(cfg *config.Config, name string) (config.DatabaseConfig, err
return db, nil
}
func newDefaultConverter() (units.Converter, error) {
switch constants.DefaultOutputUnitSystem {
case "us":
return units.USConverter{}, nil
default:
return nil, fmt.Errorf("unsupported default output unit system %q", constants.DefaultOutputUnitSystem)
func newUnitRegistry() (*units.Registry, error) {
registry := units.NewRegistry()
if err := registry.Register(units.StaticFactory{
UnitSystem: constants.UnitSystemUS,
Converter: units.USConverter{},
}); 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
require (
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2
github.com/jackc/pgx/v5 v5.8.0
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/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=

View File

@@ -3,21 +3,30 @@ package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"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/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/timeparse"
)
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 {
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 {
@@ -25,32 +34,67 @@ type AlertService interface {
}
type Server struct {
obsSvc ObservationService
fcSvc ForecastService
alertsSvc AlertService
obsSvc ObservationService
conditionsSvc ConditionsService
fcSvc ForecastService
alertsSvc AlertService
}
func NewServer(obsSvc ObservationService, fcSvc ForecastService, alertsSvc AlertService) *Server {
return &Server{obsSvc: obsSvc, fcSvc: fcSvc, alertsSvc: alertsSvc}
func NewServer(obsSvc ObservationService, conditionsSvc ConditionsService, fcSvc ForecastService, alertsSvc AlertService) *Server {
return &Server{obsSvc: obsSvc, conditionsSvc: conditionsSvc, fcSvc: fcSvc, alertsSvc: alertsSvc}
}
func (s *Server) Handler() http.Handler {
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("/alerts/current", s.handleAlertsCurrent)
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 {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
resp, err := s.obsSvc.GetCurrent(r.Context())
unitSystem, err := parseUnits(r, constants.UnitSystemUS)
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
}
@@ -63,6 +107,12 @@ func (s *Server) handleForecast(w http.ResponseWriter, r *http.Request) {
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")
ts, err := timeparse.ParseTimestamp(raw)
if err != nil {
@@ -70,7 +120,7 @@ func (s *Server) handleForecast(w http.ResponseWriter, r *http.Request) {
return
}
resp, err := s.fcSvc.GetByTimestamp(r.Context(), ts)
resp, err := s.fcSvc.GetByTimestamp(r.Context(), ts, unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch forecast")
return
@@ -94,6 +144,35 @@ func (s *Server) handleAlertsCurrent(w http.ResponseWriter, r *http.Request) {
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 {
Code string `json:"code"`
Message string `json:"message"`

View File

@@ -9,6 +9,7 @@ import (
"time"
"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/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
@@ -17,27 +18,21 @@ import (
)
type fakeObservationRepo struct {
summary ports.ObservationSummaryMetric
conditions []ports.ObservationConditionMetric
precip []string
summaryWin time.Duration
conditionsWin time.Duration
precipWin time.Duration
summary ports.ObservationCurrentConditionsMetric
summaryWindow time.Duration
observations []ports.ObservationRecordMetric
recentCount int
}
func (f *fakeObservationRepo) GetCurrentSummary(_ context.Context, window time.Duration) (ports.ObservationSummaryMetric, error) {
f.summaryWin = window
func (f *fakeObservationRepo) GetCurrentConditionsSummary(_ context.Context, window time.Duration) (ports.ObservationCurrentConditionsMetric, error) {
f.summaryWindow = window
return f.summary, nil
}
func (f *fakeObservationRepo) ListCurrentConditions(_ context.Context, window time.Duration) ([]ports.ObservationConditionMetric, error) {
f.conditionsWin = window
return f.conditions, nil
}
func (f *fakeObservationRepo) ListCurrentPrecipitationEvents(_ context.Context, window time.Duration) ([]string, error) {
f.precipWin = window
return f.precip, nil
func (f *fakeObservationRepo) ListRecentObservations(_ context.Context, count int) ([]ports.ObservationRecordMetric, error) {
f.recentCount = count
return f.observations, nil
}
type fakeForecastRepo struct {
@@ -58,15 +53,349 @@ func (fakeAlertRepo) ListCurrentAlerts(context.Context) ([]ports.AlertRecord, er
return nil, nil
}
func TestForecastInvalidTimestampReturns400(t *testing.T) {
fcRepo := &fakeForecastRepo{}
obsRepo := &fakeObservationRepo{}
func newTestUnitRegistry(t *testing.T) *units.Registry {
t.Helper()
server := NewServer(
observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow),
forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit),
reg := units.NewRegistry()
if err := reg.Register(units.StaticFactory{UnitSystem: constants.UnitSystemUS, Converter: units.USConverter{}}); err != nil {
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{}),
).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)
w := httptest.NewRecorder()
@@ -85,85 +414,7 @@ func TestForecastInvalidTimestampReturns400(t *testing.T) {
}
}
func TestObservationsCurrentReturnsUSUnits(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) {
func TestForecastUnitSelection(t *testing.T) {
tempC := 0.0
tempMinC := -1.0
tempMaxC := 1.0
@@ -189,49 +440,48 @@ func TestForecastReturnsUSUnits(t *testing.T) {
},
},
}
server := newTestHandler(t, &fakeObservationRepo{}, fcRepo)
obsRepo := &fakeObservationRepo{}
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, "/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)
wUS := httptest.NewRecorder()
server.ServeHTTP(wUS, httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z", nil))
if wUS.Code != http.StatusOK {
t.Fatalf("expected US forecast 200, got %d", wUS.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
var usPayload map[string]any
if err := json.Unmarshal(wUS.Body.Bytes(), &usPayload); err != nil {
t.Fatalf("decode us forecast: %v", err)
}
periods := payload["periods"].([]any)
first := periods[0].(map[string]any)
if got := first["temperatureF"].(float64); got != 32.0 {
usPeriod := usPayload["periods"].([]any)[0].(map[string]any)
if got := usPeriod["temperatureF"].(float64); got != 32.0 {
t.Fatalf("expected temperatureF=32.0, got %v", got)
}
if got := first["temperatureFMin"].(float64); got != 30.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 {
if got := usPeriod["windSpeedMph"].(float64); got != 6.2 {
t.Fatalf("expected windSpeedMph=6.2, got %v", got)
}
if got := first["windGustMph"].(float64); got != 10.0 {
t.Fatalf("expected windGustMph=10.0, got %v", got)
if _, exists := usPeriod["temperatureC"]; exists {
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 {
@@ -241,73 +491,3 @@ func TestForecastReturnsUSUnits(t *testing.T) {
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 (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
@@ -18,84 +19,161 @@ func NewObservationRepository(pool *pgxpool.Pool) *ObservationRepository {
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 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 {
return ports.ObservationSummaryMetric{}, fmt.Errorf("query observation summary: %w", err)
if err := r.pool.QueryRow(ctx, queryCurrentConditionsSummary, windowMinutes(window)).Scan(
&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),
ApparentTemperatureC: ptrFloat64(apparent),
DewpointC: ptrFloat64(dewpoint),
RelativeHumidity: ptrFloat64(relativeHumidity),
WindSpeedKmh: ptrFloat64(windSpeed),
WindDirectionDegrees: ptrFloat64(windDirection),
ConditionCode: ptrInt(conditionCode),
IsDay: ptrBool(isDay),
}, nil
}
func (r *ObservationRepository) ListCurrentConditions(ctx context.Context, window time.Duration) ([]ports.ObservationConditionMetric, error) {
rows, err := r.pool.Query(ctx, queryObservationConditions, windowMinutes(window))
func (r *ObservationRepository) ListRecentObservations(ctx context.Context, count int) ([]ports.ObservationRecordMetric, error) {
rows, err := r.pool.Query(ctx, queryRecentObservations, count)
if err != nil {
return nil, fmt.Errorf("query observation conditions: %w", err)
return nil, fmt.Errorf("query recent observations: %w", err)
}
defer rows.Close()
out := make([]ports.ObservationConditionMetric, 0)
out := make([]ports.ObservationRecordMetric, 0, count)
eventIDs := make([]string, 0, count)
for rows.Next() {
var eventID string
var stationID sql.NullString
var observedAt time.Time
var temperature sql.NullFloat64
var stationName sql.NullString
var timestamp time.Time
var conditionCode int
var isDay sql.NullBool
var textDescription sql.NullString
var providerRawDescription sql.NullString
var conditionText sql.NullString
var temperature sql.NullFloat64
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(
&eventID,
&stationID,
&observedAt,
&temperature,
&stationName,
&timestamp,
&conditionCode,
&isDay,
&textDescription,
&providerRawDescription,
&conditionText,
&temperature,
&dewpoint,
&windDirection,
&windSpeed,
&windGust,
&pressure,
&visibility,
&relativeHumidity,
&apparent,
); 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{
StationID: ptrString(stationID),
ObservedAt: observedAt,
TemperatureC: ptrFloat64(temperature),
TextDescription: ptrString(textDescription),
ProviderRawDescription: ptrString(providerRawDescription),
ConditionText: ptrString(conditionText),
eventIDs = append(eventIDs, eventID)
out = append(out, ports.ObservationRecordMetric{
EventID: eventID,
StationID: ptrString(stationID),
StationName: ptrString(stationName),
Timestamp: timestamp,
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 {
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
}
func (r *ObservationRepository) ListCurrentPrecipitationEvents(ctx context.Context, window time.Duration) ([]string, error) {
rows, err := r.pool.Query(ctx, queryObservationPrecipitation, windowMinutes(window))
func (r *ObservationRepository) listObservationPresentWeather(ctx context.Context, eventIDs []string) (map[string][]ports.ObservationPresentWeatherMetric, error) {
rows, err := r.pool.Query(ctx, queryObservationPresentWeather, eventIDs)
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()
out := make([]string, 0)
out := make(map[string][]ports.ObservationPresentWeatherMetric, len(eventIDs))
for rows.Next() {
var eventID string
var weatherIndex int
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 {
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 {
return nil, fmt.Errorf("iterate observation precipitation rows: %w", err)
return nil, fmt.Errorf("iterate observation present weather rows: %w", err)
}
return out, nil

View File

@@ -1,33 +1,80 @@
package postgres
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
AVG(temperature_c) AS temperature_c,
AVG(apparent_temperature_c) AS apparent_temperature_c
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1);
AVG(apparent_temperature_c) AS apparent_temperature_c,
AVG(dewpoint_c) AS dewpoint_c,
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
event_id,
station_id,
station_name,
observed_at,
temperature_c,
condition_code,
is_day,
text_description,
provider_raw_description,
condition_text
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
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
ORDER BY observed_at DESC;
ORDER BY observed_at DESC
LIMIT $1;
`
queryObservationPrecipitation = `
queryObservationPresentWeather = `
SELECT
event_id,
weather_index,
raw_text
FROM observation_present_weather
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
ORDER BY observed_at DESC;
WHERE event_id = ANY($1)
ORDER BY event_id ASC, weather_index ASC;
`
queryForecastPeriodsAt = `

View File

@@ -8,18 +8,31 @@ import (
)
func TestQueriesUseParametersNotHardcodedValues(t *testing.T) {
if !strings.Contains(queryObservationSummary, "make_interval(mins => $1)") {
t.Fatalf("observation summary query must use $1 minutes parameter")
if !strings.Contains(queryCurrentConditionsSummary, "make_interval(mins => $1)") {
t.Fatalf("current conditions summary query must use $1 minutes parameter")
}
if strings.Contains(queryObservationSummary, "30 minutes") {
t.Fatalf("observation summary query should not hardcode 30 minutes")
if strings.Contains(queryCurrentConditionsSummary, "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)") {
t.Fatalf("observation conditions query must use $1 minutes parameter")
if !strings.Contains(queryRecentObservations, "LIMIT $1") {
t.Fatalf("recent observations query must use parameterized limit")
}
if !strings.Contains(queryObservationPrecipitation, "make_interval(mins => $1)") {
t.Fatalf("observation precipitation query must use $1 minutes parameter")
if strings.Contains(queryRecentObservations, "LIMIT 5") {
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") {

View File

@@ -29,6 +29,14 @@ func ptrBool(v sql.NullBool) *bool {
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 {
if !v.Valid {
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/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
type Service struct {
repo ports.ForecastRepository
converter units.Converter
limit int
repo ports.ForecastRepository
registry *units.Registry
limit int
}
func NewService(repo ports.ForecastRepository, converter units.Converter, limit int) *Service {
return &Service{repo: repo, converter: converter, limit: limit}
func NewService(repo ports.ForecastRepository, registry *units.Registry, limit int) *Service {
return &Service{repo: repo, registry: registry, limit: limit}
}
type Response struct {
@@ -36,16 +37,23 @@ type PeriodResponse struct {
TextDescription *string `json:"textDescription,omitempty"`
DetailedText *string `json:"detailedText,omitempty"`
IconURL *string `json:"iconUrl,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
TemperatureCMin *float64 `json:"temperatureCMin,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty"`
TemperatureCMax *float64 `json:"temperatureCMax,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,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"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"`
@@ -54,11 +62,16 @@ type PeriodResponse struct {
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 {
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)
if err != nil {
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))
for _, p := range periodsMetric {
periods = append(periods, PeriodResponse{
item := PeriodResponse{
PeriodIndex: p.PeriodIndex,
StartTime: p.StartTime,
EndTime: p.EndTime,
@@ -78,23 +91,37 @@ func (s *Service) GetByTimestamp(ctx context.Context, ts time.Time) (Response, e
TextDescription: p.TextDescription,
DetailedText: p.DetailedText,
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,
WindDirectionDegrees: p.WindDirectionDegrees,
WindSpeedMph: s.converter.SpeedKmhToOutput(p.WindSpeedKmh),
WindGustMph: s.converter.SpeedKmhToOutput(p.WindGustKmh),
BarometricPressurePa: p.BarometricPressurePa,
VisibilityMeters: p.VisibilityMeters,
ApparentTemperatureF: s.converter.TemperatureCToOutput(p.ApparentTemperatureC),
CloudCoverPercent: p.CloudCoverPercent,
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitationPercent,
PrecipitationAmountMm: p.PrecipitationAmountMm,
SnowfallDepthMm: p.SnowfallDepthMm,
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{

View File

@@ -7,72 +7,103 @@ import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
type Service struct {
repo ports.ObservationRepository
converter units.Converter
window time.Duration
repo ports.ObservationRepository
registry *units.Registry
}
func NewService(repo ports.ObservationRepository, converter units.Converter, window time.Duration) *Service {
return &Service{repo: repo, converter: converter, window: window}
func NewService(repo ports.ObservationRepository, registry *units.Registry) *Service {
return &Service{repo: repo, registry: registry}
}
type CurrentResponse struct {
Summary SummaryResponse `json:"summary"`
Conditions []ConditionResponse `json:"conditions"`
PrecipitationEvents []string `json:"precipitationEvents"`
type Response struct {
Observations []ObservationResponse `json:"observations"`
}
type SummaryResponse struct {
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
type ObservationResponse struct {
StationID *string `json:"stationId,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 {
StationID *string `json:"stationId,omitempty"`
ObservedAt time.Time `json:"observedAt"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
TextDescription *string `json:"textDescription,omitempty"`
type PresentWeatherResponse struct {
Raw map[string]any `json:"raw,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 {
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 {
return CurrentResponse{}, err
return Response{}, err
}
conditionsMetric, err := s.repo.ListCurrentConditions(ctx, s.window)
records, err := s.repo.ListRecentObservations(ctx, count)
if err != nil {
return CurrentResponse{}, err
return Response{}, err
}
precip, err := s.repo.ListCurrentPrecipitationEvents(ctx, s.window)
if err != nil {
return CurrentResponse{}, err
out := make([]ObservationResponse, 0, len(records))
for _, r := range records {
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))
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
return Response{Observations: out}, nil
}

View File

@@ -9,10 +9,15 @@ import (
type Converter interface {
TemperatureCToOutput(celsius *float64) *float64
SpeedKmhToOutput(kmh *float64) *float64
System() string
}
type USConverter struct{}
func (USConverter) System() string {
return "us"
}
func (USConverter) TemperatureCToOutput(celsius *float64) *float64 {
if celsius == nil {
return nil
@@ -29,6 +34,28 @@ func (USConverter) SpeedKmhToOutput(kmh *float64) *float64 {
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 {
pow := math.Pow(10, float64(precision))
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)
}
}
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"
)
type ObservationSummaryMetric struct {
type ObservationCurrentConditionsMetric struct {
TemperatureC *float64
ApparentTemperatureC *float64
DewpointC *float64
RelativeHumidity *float64
WindSpeedKmh *float64
WindDirectionDegrees *float64
ConditionCode *int
IsDay *bool
}
type ObservationConditionMetric struct {
StationID *string
ObservedAt time.Time
TemperatureC *float64
TextDescription *string
ProviderRawDescription *string
ConditionText *string
type ObservationPresentWeatherMetric struct {
Raw map[string]any
}
type ObservationRecordMetric struct {
EventID 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 {
@@ -60,9 +81,8 @@ type AlertRecord struct {
}
type ObservationRepository interface {
GetCurrentSummary(ctx context.Context, window time.Duration) (ObservationSummaryMetric, error)
ListCurrentConditions(ctx context.Context, window time.Duration) ([]ObservationConditionMetric, error)
ListCurrentPrecipitationEvents(ctx context.Context, window time.Duration) ([]string, error)
GetCurrentConditionsSummary(ctx context.Context, window time.Duration) (ObservationCurrentConditionsMetric, error)
ListRecentObservations(ctx context.Context, count int) ([]ObservationRecordMetric, error)
}
type ForecastRepository interface {

View File

@@ -6,11 +6,23 @@ const (
// ObservationWindow defines how far back observations are queried.
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 = 5
// DefaultOutputUnitSystem is the API's default output unit system.
DefaultOutputUnitSystem = "us"
// UnitSystemUS identifies the US customary unit system.
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 = ":8080"