8 Commits

Author SHA1 Message Date
4b97a4c062 Removed the integer condition code from the output at the conditions/current endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-17 19:35:40 -05:00
8711fc457c Added circular average logic to wind direction, and removed condition code from the current conditions endpoint
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-17 19:25:31 -05:00
d293c40765 Removed test condition for the wind direction circular mean (will add as future feature)
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-17 17:06:05 -05:00
051a6d7427 Fixed a bug in the current conditions query (prior function not implemented by postgres)
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-17 16:13:00 -05:00
cb316c228a 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
2026-03-17 15:51:00 -05:00
e3aab86376 Removed deprecated text fields and WindowMinutes from the observations endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-17 11:15:29 -05:00
0d82e5d60e Add omitempty to JSON fields in alert, forecast, and observation services
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-17 09:03:23 -05:00
27817f9e43 Initial MVP commit
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-17 08:35:16 -05:00
42 changed files with 2716 additions and 1238 deletions

View File

@@ -0,0 +1,17 @@
when:
# Allow both normal runs (push) and UI-triggered runs (manual)
- event: [push, manual]
steps:
- name: build-and-push-image
image: harbor.maximumdirect.net/proxy-dockerhub/woodpeckerci/plugin-kaniko
settings:
registry: harbor.maximumdirect.net
repo: build/weatherapi
auto_tag: true
username:
from_secret: HARBOR_ROBOT_USER
password:
from_secret: HARBOR_ROBOT_TOKEN
cache: true
cache_repo: build-cache/weatherapi

78
Dockerfile Normal file
View File

@@ -0,0 +1,78 @@
# syntax=docker/dockerfile:1.6
ARG GO_VERSION=1.25
############################
# Build stage
############################
FROM harbor.maximumdirect.net/proxy-dockerhub/golang:${GO_VERSION}-bookworm AS build
WORKDIR /src
# Install baseline packages
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata git build-essential \
&& rm -rf /var/lib/apt/lists/*
# Cache dependencies first
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy the rest of the source
COPY . .
# Ensure go.sum is complete after dropping the replace
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build
# Default to a static build (no CGO)
# If errors, can build with: --build-arg CGO_ENABLED=1
ARG CGO_ENABLED=0
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ENV CGO_ENABLED=${CGO_ENABLED} \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
# Run tests before building the final binary
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go test ./...
# Build the cmd entrypoint
RUN --mount=type=cache,target=/root/.cache/go-build \
go build \
-trimpath \
-ldflags="-s -w" \
-o /out/weatherapi \
./cmd/weatherapi
############################
# Runtime stage
############################
FROM harbor.maximumdirect.net/proxy-dockerhub/debian:bookworm-slim AS runtime
# Install runtime necessities
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata curl \
&& rm -rf /var/lib/apt/lists/*
# Define /weatherapi as the working directory
WORKDIR /weatherapi
# Create an unprivileged user
RUN useradd \
--uid 10001 \
--no-create-home \
--shell /usr/sbin/nologin \
weatherapi
# Copy the binary
COPY --chown=weatherapi:weatherapi --from=build /out/weatherapi /weatherapi/weatherapi
USER weatherapi
# The application expects config.yml in the same directory as the binary
ENTRYPOINT ["/weatherapi/weatherapi"]

View File

@@ -1,3 +1,42 @@
# weatherapi # weatherapi
A small HTTP API that serves a variety of weather-related endpoints. `weatherapi` is a small HTTP API that serves weather data backed by the
PostgreSQL schema populated by `weatherfeeder`.
## Config
`weatherapi` reads a YAML config file that is either:
- a top-level list of database entries, or
- an object with `databases:`.
Example:
```yaml
- name: weatherdb
driver: postgres
params:
uri: postgres://weatherdb:5432/weatherdb?sslmode=disable
username: weatherdb
password: weatherdb
```
## Endpoints
- `GET /conditions/current`
- `GET /observations?count=<1..100>&units=<us|metric>`
- `GET /forecast?timestamp=<RFC3339 timestamp>`
- `GET /alerts/current`
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
```bash
go run ./cmd/weatherapi -config ./config.yml -addr :8080
```

View File

@@ -5,79 +5,108 @@ import (
"flag" "flag"
"fmt" "fmt"
"log" "log"
"os" "net/http"
"os/signal"
"strings"
"syscall"
feedapp "gitea.maximumdirect.net/ejr/feedapi/app" "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/httpapi"
"gitea.maximumdirect.net/ejr/feedapi/config" adapterpg "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/postgres"
"gitea.maximumdirect.net/ejr/feedapi/db" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
httpapi "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/conditions"
wfpq "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/outbound/postgres" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
_ "github.com/lib/pq" "gitea.maximumdirect.net/ejr/weatherapi/internal/platform/config"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/datasource"
dspostgres "gitea.maximumdirect.net/ejr/weatherapi/internal/platform/datasource/postgres"
) )
func main() { func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds) cfgPath := flag.String("config", "config.yml", "path to YAML config")
addr := flag.String("addr", constants.DefaultHTTPAddr, "HTTP listen address")
cfgPath := flag.String("config", envOrDefault("WEATHERAPI_CONFIG", "config.yml"), "Path to config YAML") dbName := flag.String("db-name", "", "optional configured database name to use")
flag.Parse() flag.Parse()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) cfg, err := config.Load(*cfgPath)
defer cancel() if err != nil {
log.Fatalf("load config: %v", err)
}
if err := run(ctx, *cfgPath); err != nil { dbCfg, err := chooseDatabase(cfg, *dbName)
log.Fatalf("weatherapi failed: %v", err) if err != nil {
log.Fatalf("select database: %v", err)
}
registry := datasource.NewRegistry()
if err := registry.Register(dspostgres.Factory{}); err != nil {
log.Fatalf("register datasource factories: %v", err)
}
ds, err := registry.Open(context.Background(), dbCfg)
if err != nil {
log.Fatalf("open datasource %q: %v", dbCfg.Name, err)
}
defer ds.Close()
pgDataSource, ok := ds.(*dspostgres.DataSource)
if !ok {
log.Fatalf("unsupported datasource type %T for driver %q", ds, dbCfg.Driver)
}
unitRegistry, err := newUnitRegistry()
if err != nil {
log.Fatalf("configure units: %v", err)
}
obsRepo := adapterpg.NewObservationRepository(pgDataSource.Pool())
fcRepo := adapterpg.NewForecastRepository(pgDataSource.Pool())
alertRepo := adapterpg.NewAlertRepository(pgDataSource.Pool())
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, conditionsSvc, fcSvc, alertsSvc)
httpServer := &http.Server{
Addr: *addr,
Handler: server.Handler(),
}
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)
} }
} }
func run(ctx context.Context, cfgPath string) error { func chooseDatabase(cfg *config.Config, name string) (config.DatabaseConfig, error) {
cfg, err := config.Load(cfgPath) if cfg == nil {
if err != nil { return config.DatabaseConfig{}, fmt.Errorf("config is nil")
return fmt.Errorf("load config: %w", err)
} }
if len(cfg.Databases) == 0 { if name == "" {
return fmt.Errorf("config.databases requires at least one entry") return cfg.Databases[0], nil
} }
reg, err := db.OpenAll(cfg.Databases) db, ok := cfg.FindDatabase(name)
if err != nil { if !ok {
return fmt.Errorf("open databases: %w", err) return config.DatabaseConfig{}, fmt.Errorf("database %q not found in config", name)
} }
defer func() { return db, nil
if cerr := reg.Close(); cerr != nil {
log.Printf("database close error: %v", cerr)
}
}()
primaryName := cfg.Databases[0].Name
primary, err := reg.Get(primaryName)
if err != nil {
return fmt.Errorf("select primary database %q: %w", primaryName, err)
}
repo := wfpq.NewRepository(primary)
svc := core.NewService(repo)
defs := httpapi.Definitions(svc)
a, err := feedapp.New(cfg,
feedapp.WithDBRegistry(reg),
feedapp.WithEndpoints(defs...),
)
if err != nil {
return fmt.Errorf("build app: %w", err)
}
return a.Start(ctx)
} }
func envOrDefault(key, fallback string) string { func newUnitRegistry() (*units.Registry, error) {
v := strings.TrimSpace(os.Getenv(key)) registry := units.NewRegistry()
if v == "" { if err := registry.Register(units.StaticFactory{
return fallback UnitSystem: constants.UnitSystemUS,
Converter: units.USConverter{},
}); err != nil {
return nil, err
} }
return v if err := registry.Register(units.StaticFactory{
UnitSystem: constants.UnitSystemMetric,
Converter: units.MetricConverter{},
}); err != nil {
return nil, err
}
return registry, nil
} }

6
config.example.yml Normal file
View File

@@ -0,0 +1,6 @@
- name: weatherdb
driver: postgres
params:
uri: postgres://weatherdb:5432/weatherdb?sslmode=disable
username: weatherdb
password: weatherdb

View File

@@ -1,13 +0,0 @@
server:
listen_addr: ":8080"
default_format: json
databases:
- name: weatherdb
driver: postgres
uri: postgres://weatherdb:5432/weatherdb?sslmode=disable
username: weatherdb
password: weatherdb
templates:
base_dir: templates

20
go.mod
View File

@@ -3,13 +3,17 @@ module gitea.maximumdirect.net/ejr/weatherapi
go 1.25.5 go 1.25.5
require ( require (
gitea.maximumdirect.net/ejr/feedapi v0.0.0 gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2
gitea.maximumdirect.net/ejr/weatherfeeder v0.0.0 github.com/jackc/pgx/v5 v5.8.0
github.com/lib/pq v1.10.9 gopkg.in/yaml.v3 v3.0.1
) )
require gopkg.in/yaml.v3 v3.0.1 // indirect require (
github.com/jackc/pgpassfile v1.0.0 // indirect
replace gitea.maximumdirect.net/ejr/feedapi => ../feedapi github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
replace gitea.maximumdirect.net/ejr/weatherfeeder => ../weatherfeeder github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

37
go.sum
View File

@@ -1,6 +1,37 @@
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2 h1:GJdZ9x54HLTPidPGhD/dmq7efvGjBltK1ywCIRU1w6c=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2/go.mod h1:P7rP7XftJjBFzNEIkAiX+z2YqXOtnZjy3BaF6kX+sO4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,189 @@
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 {
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, unitSystem string) (forecasts.Response, error)
}
type AlertService interface {
GetCurrent(ctx context.Context) (alerts.Response, error)
}
type Server struct {
obsSvc ObservationService
conditionsSvc ConditionsService
fcSvc ForecastService
alertsSvc AlertService
}
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", s.handleObservations)
mux.HandleFunc("/conditions/current", s.handleConditionsCurrent)
mux.HandleFunc("/forecast", s.handleForecast)
mux.HandleFunc("/alerts/current", s.handleAlertsCurrent)
return mux
}
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
}
unitSystem, err := parseUnits(r, constants.UnitSystemUS)
if err != nil {
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
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleForecast(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.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 {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
resp, err := s.fcSvc.GetByTimestamp(r.Context(), ts, unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch forecast")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleAlertsCurrent(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.alertsSvc.GetCurrent(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch current alerts")
return
}
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"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Code: code, Message: message})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}

View File

@@ -0,0 +1,493 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"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"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
type fakeObservationRepo struct {
summary ports.ObservationCurrentConditionsMetric
summaryWindow time.Duration
observations []ports.ObservationRecordMetric
recentCount int
}
func (f *fakeObservationRepo) GetCurrentConditionsSummary(_ context.Context, window time.Duration) (ports.ObservationCurrentConditionsMetric, error) {
f.summaryWindow = window
return f.summary, nil
}
func (f *fakeObservationRepo) ListRecentObservations(_ context.Context, count int) ([]ports.ObservationRecordMetric, error) {
f.recentCount = count
return f.observations, nil
}
type fakeForecastRepo struct {
periods []ports.ForecastPeriodMetric
gotTS time.Time
gotLimit int
}
func (f *fakeForecastRepo) ListForecastPeriodsAt(_ context.Context, ts time.Time, limit int) ([]ports.ForecastPeriodMetric, error) {
f.gotTS = ts
f.gotLimit = limit
return f.periods, nil
}
type fakeAlertRepo struct{}
func (fakeAlertRepo) ListCurrentAlerts(context.Context) ([]ports.AlertRecord, error) {
return nil, nil
}
func newTestUnitRegistry(t *testing.T) *units.Registry {
t.Helper()
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["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["conditionCode"]; exists {
t.Fatalf("did not expect conditionCode in conditions response")
}
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["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["conditionCode"]; exists {
t.Fatalf("did not expect conditionCode in conditions response")
}
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()
server.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, 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 payload["code"] != "invalid_request" {
t.Fatalf("expected invalid_request code, got %v", payload["code"])
}
}
func TestForecastUnitSelection(t *testing.T) {
tempC := 0.0
tempMinC := -1.0
tempMaxC := 1.0
appTempC := -2.0
windKmh := 10.0
gustKmh := 16.09344
name := "Now"
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),
Name: &name,
ConditionCode: 1,
TemperatureC: &tempC,
TemperatureCMin: &tempMinC,
TemperatureCMax: &tempMaxC,
ApparentTemperatureC: &appTempC,
WindSpeedKmh: &windKmh,
WindGustKmh: &gustKmh,
},
},
}
server := newTestHandler(t, &fakeObservationRepo{}, fcRepo)
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 usPayload map[string]any
if err := json.Unmarshal(wUS.Body.Bytes(), &usPayload); err != nil {
t.Fatalf("decode us forecast: %v", err)
}
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 := usPeriod["windSpeedMph"].(float64); got != 6.2 {
t.Fatalf("expected windSpeedMph=6.2, got %v", got)
}
if _, exists := usPeriod["temperatureC"]; exists {
t.Fatalf("did not expect metric key temperatureC 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 {
t.Fatalf("expected forecast query limit %d, got %d", constants.ForecastQueryLimit, fcRepo.gotLimit)
}
if fcRepo.gotTS.IsZero() {
t.Fatalf("expected forecast timestamp passed to repo")
}
}

View File

@@ -1,76 +0,0 @@
package httpapi
import (
"context"
"net/http"
"gitea.maximumdirect.net/ejr/feedapi/bind"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Service describes the weather use-cases needed by the HTTP adapter.
type Service interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error)
}
type emptyRequest struct{}
func Definitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{
endpoint.GET(
"/observations",
bindFormatOnly,
func(ctx context.Context, _ emptyRequest) (any, error) {
obs, err := svc.LatestObservation(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: obs}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("observations.txt.tmpl"),
),
endpoint.GET(
"/forecast/hourly",
bindFormatOnly,
func(ctx context.Context, _ emptyRequest) (any, error) {
run, err := svc.LatestHourlyForecast(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: run}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("forecast_hourly.txt.tmpl"),
),
endpoint.GET(
"/alerts/active",
bindFormatOnly,
func(ctx context.Context, _ emptyRequest) (any, error) {
run, err := svc.LatestActiveAlerts(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: run}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("alerts_active.txt.tmpl"),
),
}
}
func bindFormatOnly(r *http.Request) (emptyRequest, error) {
_, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowFormat: true,
RejectUnknown: true,
})
if err != nil {
return emptyRequest{}, err
}
return emptyRequest{}, nil
}

View File

@@ -1,195 +0,0 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
apierrors "gitea.maximumdirect.net/ejr/feedapi/errors"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/templates"
"gitea.maximumdirect.net/ejr/feedapi/transport/httpx"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type fakeService struct {
observation *model.WeatherObservation
forecast *model.WeatherForecastRun
alerts *model.WeatherAlertRun
err error
}
func (s *fakeService) LatestObservation(context.Context) (*model.WeatherObservation, error) {
return s.observation, s.err
}
func (s *fakeService) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
return s.forecast, s.err
}
func (s *fakeService) LatestActiveAlerts(context.Context) (*model.WeatherAlertRun, error) {
return s.alerts, s.err
}
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
h := newHandler(t, &fakeService{}, "/observations")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/observations?bogus=1", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var env apierrors.Envelope
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode error envelope: %v", err)
}
if env.Error == nil || env.Error.Code != apierrors.CodeInvalidParameter {
t.Fatalf("expected invalid_parameter code, got %+v", env.Error)
}
}
func TestObservationsNoDataReturnsNullEnvelopeData(t *testing.T) {
h := newHandler(t, &fakeService{}, "/observations")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/observations", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data *json.RawMessage `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data != nil {
t.Fatalf("expected data null, got %s", string(*payload.Data))
}
}
func TestObservationsPopulatedJSONEnvelope(t *testing.T) {
now := time.Date(2026, 3, 19, 18, 0, 0, 0, time.UTC)
h := newHandler(t, &fakeService{
observation: &model.WeatherObservation{
StationID: "KSTL",
StationName: "St. Louis",
Timestamp: now,
},
}, "/observations")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/observations", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data struct {
StationID string `json:"stationId"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data.StationID != "KSTL" {
t.Fatalf("expected stationId KSTL, got %q", payload.Data.StationID)
}
}
func TestFormatNegotiationXMLAndText(t *testing.T) {
hXML := newHandler(t, &fakeService{alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}}, "/alerts/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
req.Header.Set("Accept", "application/xml")
hXML.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for xml request, got %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
}
hText := newHandler(t, &fakeService{forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly}}, "/forecast/hourly")
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=text", nil)
hText.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for text request, got %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Body.String(), "Forecast text") {
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
}
}
func newHandler(t *testing.T, svc Service, path string) http.Handler {
t.Helper()
def := definitionForPath(t, Definitions(svc), path)
return httpx.Adapt(def, httpx.Dependencies{
Renderers: testRenderers(t),
DefaultFormat: render.FormatJSON,
})
}
func definitionForPath(t *testing.T, defs []endpoint.Definition, path string) endpoint.Definition {
t.Helper()
for _, def := range defs {
if def.Path == path {
return def
}
}
t.Fatalf("endpoint not found: %s", path)
return endpoint.Definition{}
}
func testRenderers(t *testing.T) *render.Registry {
t.Helper()
reg := render.NewRegistry()
if err := reg.Register(render.NewJSONRenderer()); err != nil {
t.Fatal(err)
}
if err := reg.Register(render.NewXMLRenderer()); err != nil {
t.Fatal(err)
}
tmplReg := templates.NewRegistry()
for name, body := range map[string]string{
"observations.txt.tmpl": "Observation text",
"forecast_hourly.txt.tmpl": "Forecast text",
"alerts_active.txt.tmpl": "Alerts text",
} {
tmpl, err := template.New(name).Parse(body)
if err != nil {
t.Fatal(err)
}
if err := tmplReg.Register(name, tmpl); err != nil {
t.Fatal(err)
}
}
if err := reg.Register(templates.NewRenderer(tmplReg)); err != nil {
t.Fatal(err)
}
return reg
}

View File

@@ -1,688 +0,0 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
const (
queryLatestObservation = `
SELECT
event_id,
station_id,
station_name,
observed_at,
condition_code,
is_day,
text_description,
temperature_c,
dewpoint_c,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
relative_humidity_percent,
apparent_temperature_c
FROM observations
ORDER BY observed_at DESC, event_emitted_at DESC
LIMIT 1`
queryObservationPresentWeather = `
SELECT weather_index, raw_text
FROM observation_present_weather
WHERE event_id = $1
ORDER BY weather_index ASC`
queryLatestHourlyForecast = `
SELECT
event_id,
location_id,
location_name,
issued_at,
updated_at,
product,
latitude,
longitude,
elevation_meters
FROM forecasts
WHERE product = 'hourly'
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryForecastPeriods = `
SELECT
period_index,
start_time,
end_time,
name,
is_day,
condition_code,
condition_text,
provider_raw_description,
text_description,
detailed_text,
icon_url,
temperature_c,
temperature_c_min,
temperature_c_max,
dewpoint_c,
relative_humidity_percent,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
apparent_temperature_c,
cloud_cover_percent,
probability_of_precipitation_percent,
precipitation_amount_mm,
snowfall_depth_mm,
uv_index
FROM forecast_periods
WHERE run_event_id = $1
ORDER BY period_index ASC`
queryLatestAlertRun = `
SELECT
event_id,
location_id,
location_name,
as_of,
latitude,
longitude
FROM alert_runs
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1`
queryAlerts = `
SELECT
alert_index,
alert_id,
event,
headline,
severity,
urgency,
certainty,
status,
message_type,
category,
response,
description,
instruction,
sent,
effective,
onset,
expires,
area_description,
sender_name
FROM alerts
WHERE run_event_id = $1
ORDER BY alert_index ASC`
queryAlertReferences = `
SELECT
alert_index,
id,
identifier,
sender,
sent
FROM alert_references
WHERE run_event_id = $1
ORDER BY alert_index ASC, reference_index ASC`
)
// Repository is a Postgres-backed implementation of weatherapi read ports.
type Repository struct {
db *sql.DB
}
var _ core.Repository = (*Repository)(nil)
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row observationParentRow
err := r.db.QueryRowContext(ctx, queryLatestObservation).Scan(
&row.EventID,
&row.StationID,
&row.StationName,
&row.ObservedAt,
&row.ConditionCode,
&row.IsDay,
&row.TextDescription,
&row.TemperatureC,
&row.DewpointC,
&row.WindDirectionDegrees,
&row.WindSpeedKmh,
&row.WindGustKmh,
&row.BarometricPressurePa,
&row.VisibilityMeters,
&row.RelativeHumidityPercent,
&row.ApparentTemperatureC,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest observation: %w", err)
}
obs := mapObservationParentRow(row)
presentWeather, err := r.loadObservationPresentWeather(ctx, row.EventID)
if err != nil {
return nil, err
}
obs.PresentWeather = presentWeather
return &obs, nil
}
func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row forecastParentRow
err := r.db.QueryRowContext(ctx, queryLatestHourlyForecast).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.IssuedAt,
&row.UpdatedAt,
&row.Product,
&row.Latitude,
&row.Longitude,
&row.ElevationMeters,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest hourly forecast: %w", err)
}
run := mapForecastParentRow(row)
periods, err := r.loadForecastPeriods(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Periods = periods
return &run, nil
}
func (r *Repository) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row alertRunParentRow
err := r.db.QueryRowContext(ctx, queryLatestAlertRun).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.AsOf,
&row.Latitude,
&row.Longitude,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest alert run: %w", err)
}
run := mapAlertRunParentRow(row)
alerts, err := r.loadAlerts(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Alerts = alerts
return &run, nil
}
func (r *Repository) loadObservationPresentWeather(ctx context.Context, eventID string) ([]model.PresentWeather, error) {
rows, err := r.db.QueryContext(ctx, queryObservationPresentWeather, eventID)
if err != nil {
return nil, fmt.Errorf("query observation present weather: %w", err)
}
defer rows.Close()
out := make([]model.PresentWeather, 0)
for rows.Next() {
var row observationPresentWeatherRow
if err := rows.Scan(&row.WeatherIndex, &row.RawText); err != nil {
return nil, fmt.Errorf("scan observation present weather row: %w", err)
}
pw, err := mapObservationPresentWeatherRow(row)
if err != nil {
return nil, fmt.Errorf("decode observation present weather row (index=%d): %w", row.WeatherIndex, err)
}
out = append(out, pw)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation present weather rows: %w", err)
}
return out, nil
}
func (r *Repository) loadForecastPeriods(ctx context.Context, eventID string) ([]model.WeatherForecastPeriod, error) {
rows, err := r.db.QueryContext(ctx, queryForecastPeriods, eventID)
if err != nil {
return nil, fmt.Errorf("query forecast periods: %w", err)
}
defer rows.Close()
out := make([]model.WeatherForecastPeriod, 0)
for rows.Next() {
var row forecastPeriodRow
if err := rows.Scan(
&row.PeriodIndex,
&row.StartTime,
&row.EndTime,
&row.Name,
&row.IsDay,
&row.ConditionCode,
&row.ConditionText,
&row.ProviderRawDescription,
&row.TextDescription,
&row.DetailedText,
&row.IconURL,
&row.TemperatureC,
&row.TemperatureCMin,
&row.TemperatureCMax,
&row.DewpointC,
&row.RelativeHumidityPercent,
&row.WindDirectionDegrees,
&row.WindSpeedKmh,
&row.WindGustKmh,
&row.BarometricPressurePa,
&row.VisibilityMeters,
&row.ApparentTemperatureC,
&row.CloudCoverPercent,
&row.ProbabilityOfPrecipitationPercent,
&row.PrecipitationAmountMM,
&row.SnowfallDepthMM,
&row.UVIndex,
); err != nil {
return nil, fmt.Errorf("scan forecast period row: %w", err)
}
out = append(out, mapForecastPeriodRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate forecast period rows: %w", err)
}
return out, nil
}
func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.WeatherAlert, error) {
alertsRows, err := r.db.QueryContext(ctx, queryAlerts, eventID)
if err != nil {
return nil, fmt.Errorf("query alerts: %w", err)
}
defer alertsRows.Close()
indexedAlerts := make([]indexedAlert, 0)
for alertsRows.Next() {
var row alertRow
if err := alertsRows.Scan(
&row.AlertIndex,
&row.AlertID,
&row.Event,
&row.Headline,
&row.Severity,
&row.Urgency,
&row.Certainty,
&row.Status,
&row.MessageType,
&row.Category,
&row.Response,
&row.Description,
&row.Instruction,
&row.Sent,
&row.Effective,
&row.Onset,
&row.Expires,
&row.AreaDescription,
&row.SenderName,
); err != nil {
return nil, fmt.Errorf("scan alerts row: %w", err)
}
indexedAlerts = append(indexedAlerts, mapAlertRow(row))
}
if err := alertsRows.Err(); err != nil {
return nil, fmt.Errorf("iterate alerts rows: %w", err)
}
referenceRows, err := r.db.QueryContext(ctx, queryAlertReferences, eventID)
if err != nil {
return nil, fmt.Errorf("query alert references: %w", err)
}
defer referenceRows.Close()
indexedReferences := make([]indexedAlertReference, 0)
for referenceRows.Next() {
var row alertReferenceRow
if err := referenceRows.Scan(
&row.AlertIndex,
&row.ID,
&row.Identifier,
&row.Sender,
&row.Sent,
); err != nil {
return nil, fmt.Errorf("scan alert reference row: %w", err)
}
indexedReferences = append(indexedReferences, mapAlertReferenceRow(row))
}
if err := referenceRows.Err(); err != nil {
return nil, fmt.Errorf("iterate alert reference rows: %w", err)
}
return attachAlertReferences(indexedAlerts, indexedReferences), nil
}
type observationParentRow struct {
EventID string
StationID sql.NullString
StationName sql.NullString
ObservedAt time.Time
ConditionCode int
IsDay sql.NullBool
TextDescription sql.NullString
TemperatureC sql.NullFloat64
DewpointC sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindGustKmh sql.NullFloat64
BarometricPressurePa sql.NullFloat64
VisibilityMeters sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
}
func mapObservationParentRow(row observationParentRow) model.WeatherObservation {
return model.WeatherObservation{
StationID: stringValue(row.StationID),
StationName: stringValue(row.StationName),
Timestamp: row.ObservedAt.UTC(),
ConditionCode: model.WMOCode(row.ConditionCode),
IsDay: boolPtr(row.IsDay),
TextDescription: stringValue(row.TextDescription),
TemperatureC: float64Ptr(row.TemperatureC),
DewpointC: float64Ptr(row.DewpointC),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindGustKmh: float64Ptr(row.WindGustKmh),
BarometricPressurePa: float64Ptr(row.BarometricPressurePa),
VisibilityMeters: float64Ptr(row.VisibilityMeters),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
}
}
type observationPresentWeatherRow struct {
WeatherIndex int
RawText sql.NullString
}
func mapObservationPresentWeatherRow(row observationPresentWeatherRow) (model.PresentWeather, error) {
if !row.RawText.Valid || strings.TrimSpace(row.RawText.String) == "" {
return model.PresentWeather{}, nil
}
var raw map[string]any
if err := json.Unmarshal([]byte(row.RawText.String), &raw); err != nil {
return model.PresentWeather{}, err
}
return model.PresentWeather{Raw: raw}, nil
}
type forecastParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
IssuedAt time.Time
UpdatedAt sql.NullTime
Product string
Latitude sql.NullFloat64
Longitude sql.NullFloat64
ElevationMeters sql.NullFloat64
}
func mapForecastParentRow(row forecastParentRow) model.WeatherForecastRun {
return model.WeatherForecastRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
IssuedAt: row.IssuedAt.UTC(),
UpdatedAt: timePtr(row.UpdatedAt),
Product: model.ForecastProduct(row.Product),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
ElevationMeters: float64Ptr(row.ElevationMeters),
}
}
type forecastPeriodRow struct {
PeriodIndex int
StartTime time.Time
EndTime time.Time
Name sql.NullString
IsDay sql.NullBool
ConditionCode int
ConditionText sql.NullString
ProviderRawDescription sql.NullString
TextDescription sql.NullString
DetailedText sql.NullString
IconURL sql.NullString
TemperatureC sql.NullFloat64
TemperatureCMin sql.NullFloat64
TemperatureCMax sql.NullFloat64
DewpointC sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindGustKmh sql.NullFloat64
BarometricPressurePa sql.NullFloat64
VisibilityMeters sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
CloudCoverPercent sql.NullFloat64
ProbabilityOfPrecipitationPercent sql.NullFloat64
PrecipitationAmountMM sql.NullFloat64
SnowfallDepthMM sql.NullFloat64
UVIndex sql.NullFloat64
}
func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod {
return model.WeatherForecastPeriod{
StartTime: row.StartTime.UTC(),
EndTime: row.EndTime.UTC(),
Name: stringValue(row.Name),
IsDay: boolPtr(row.IsDay),
ConditionCode: model.WMOCode(row.ConditionCode),
ConditionText: stringValue(row.ConditionText),
ProviderRawDescription: stringValue(row.ProviderRawDescription),
TextDescription: stringValue(row.TextDescription),
DetailedText: stringValue(row.DetailedText),
IconURL: stringValue(row.IconURL),
TemperatureC: float64Ptr(row.TemperatureC),
TemperatureCMin: float64Ptr(row.TemperatureCMin),
TemperatureCMax: float64Ptr(row.TemperatureCMax),
DewpointC: float64Ptr(row.DewpointC),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindGustKmh: float64Ptr(row.WindGustKmh),
BarometricPressurePa: float64Ptr(row.BarometricPressurePa),
VisibilityMeters: float64Ptr(row.VisibilityMeters),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
CloudCoverPercent: float64Ptr(row.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: float64Ptr(row.ProbabilityOfPrecipitationPercent),
PrecipitationAmountMm: float64Ptr(row.PrecipitationAmountMM),
SnowfallDepthMM: float64Ptr(row.SnowfallDepthMM),
UVIndex: float64Ptr(row.UVIndex),
}
}
type alertRunParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
AsOf time.Time
Latitude sql.NullFloat64
Longitude sql.NullFloat64
}
func mapAlertRunParentRow(row alertRunParentRow) model.WeatherAlertRun {
return model.WeatherAlertRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
AsOf: row.AsOf.UTC(),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
}
}
type alertRow struct {
AlertIndex int
AlertID string
Event sql.NullString
Headline sql.NullString
Severity sql.NullString
Urgency sql.NullString
Certainty sql.NullString
Status sql.NullString
MessageType sql.NullString
Category sql.NullString
Response sql.NullString
Description sql.NullString
Instruction sql.NullString
Sent sql.NullTime
Effective sql.NullTime
Onset sql.NullTime
Expires sql.NullTime
AreaDescription sql.NullString
SenderName sql.NullString
}
type indexedAlert struct {
Index int
Alert model.WeatherAlert
}
func mapAlertRow(row alertRow) indexedAlert {
return indexedAlert{
Index: row.AlertIndex,
Alert: model.WeatherAlert{
ID: row.AlertID,
Event: stringValue(row.Event),
Headline: stringValue(row.Headline),
Severity: stringValue(row.Severity),
Urgency: stringValue(row.Urgency),
Certainty: stringValue(row.Certainty),
Status: stringValue(row.Status),
MessageType: stringValue(row.MessageType),
Category: stringValue(row.Category),
Response: stringValue(row.Response),
Description: stringValue(row.Description),
Instruction: stringValue(row.Instruction),
Sent: timePtr(row.Sent),
Effective: timePtr(row.Effective),
Onset: timePtr(row.Onset),
Expires: timePtr(row.Expires),
AreaDescription: stringValue(row.AreaDescription),
SenderName: stringValue(row.SenderName),
},
}
}
type alertReferenceRow struct {
AlertIndex int
ID sql.NullString
Identifier sql.NullString
Sender sql.NullString
Sent sql.NullTime
}
type indexedAlertReference struct {
AlertIndex int
Reference model.AlertReference
}
func mapAlertReferenceRow(row alertReferenceRow) indexedAlertReference {
return indexedAlertReference{
AlertIndex: row.AlertIndex,
Reference: model.AlertReference{
ID: stringValue(row.ID),
Identifier: stringValue(row.Identifier),
Sender: stringValue(row.Sender),
Sent: timePtr(row.Sent),
},
}
}
func attachAlertReferences(alerts []indexedAlert, references []indexedAlertReference) []model.WeatherAlert {
refsByAlertIndex := make(map[int][]model.AlertReference, len(alerts))
for _, ref := range references {
refsByAlertIndex[ref.AlertIndex] = append(refsByAlertIndex[ref.AlertIndex], ref.Reference)
}
out := make([]model.WeatherAlert, 0, len(alerts))
for _, alert := range alerts {
mapped := alert.Alert
if refs := refsByAlertIndex[alert.Index]; len(refs) > 0 {
mapped.References = refs
}
out = append(out, mapped)
}
return out
}
func stringValue(v sql.NullString) string {
if !v.Valid {
return ""
}
return v.String
}
func boolPtr(v sql.NullBool) *bool {
if !v.Valid {
return nil
}
b := v.Bool
return &b
}
func float64Ptr(v sql.NullFloat64) *float64 {
if !v.Valid {
return nil
}
f := v.Float64
return &f
}
func timePtr(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time.UTC()
return &t
}

View File

@@ -1,112 +0,0 @@
package postgres
import (
"database/sql"
"testing"
"time"
)
func TestMapObservationParentRowNullables(t *testing.T) {
observedAt := time.Date(2026, 3, 19, 23, 45, 0, 0, time.FixedZone("CST", -6*3600))
isDay := true
temp := 18.25
obs := mapObservationParentRow(observationParentRow{
StationID: sql.NullString{String: "KSTL", Valid: true},
StationName: sql.NullString{String: "St. Louis", Valid: true},
ObservedAt: observedAt,
ConditionCode: 2,
IsDay: sql.NullBool{Bool: isDay, Valid: true},
TemperatureC: sql.NullFloat64{Float64: temp, Valid: true},
TextDescription: sql.NullString{String: "Partly Cloudy", Valid: true},
})
if obs.StationID != "KSTL" {
t.Fatalf("expected station id KSTL, got %q", obs.StationID)
}
if obs.IsDay == nil || !*obs.IsDay {
t.Fatalf("expected isDay pointer true, got %v", obs.IsDay)
}
if obs.TemperatureC == nil || *obs.TemperatureC != temp {
t.Fatalf("expected temperature %v, got %v", temp, obs.TemperatureC)
}
if obs.DewpointC != nil {
t.Fatalf("expected nil dewpoint, got %v", *obs.DewpointC)
}
if got := obs.Timestamp.Location().String(); got != "UTC" {
t.Fatalf("expected UTC timestamp, got %s", got)
}
}
func TestMapObservationPresentWeatherRow(t *testing.T) {
row := observationPresentWeatherRow{
WeatherIndex: 1,
RawText: sql.NullString{String: `{"code":61,"text":"rain"}`, Valid: true},
}
pw, err := mapObservationPresentWeatherRow(row)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pw.Raw == nil {
t.Fatalf("expected raw map to be populated")
}
if got, ok := pw.Raw["text"].(string); !ok || got != "rain" {
t.Fatalf("expected raw text rain, got %#v", pw.Raw["text"])
}
}
func TestMapForecastPeriodRowNullables(t *testing.T) {
start := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
end := start.Add(1 * time.Hour)
period := mapForecastPeriodRow(forecastPeriodRow{
StartTime: start,
EndTime: end,
ConditionCode: 80,
Name: sql.NullString{String: "Midnight", Valid: true},
TemperatureC: sql.NullFloat64{Float64: 12.5, Valid: true},
TemperatureCMin: sql.NullFloat64{Valid: false},
})
if period.Name != "Midnight" {
t.Fatalf("expected period name Midnight, got %q", period.Name)
}
if period.TemperatureC == nil || *period.TemperatureC != 12.5 {
t.Fatalf("expected temperature pointer 12.5, got %v", period.TemperatureC)
}
if period.TemperatureCMin != nil {
t.Fatalf("expected nil TemperatureCMin, got %v", *period.TemperatureCMin)
}
if !period.StartTime.Equal(start) || !period.EndTime.Equal(end) {
t.Fatalf("unexpected time range: %s - %s", period.StartTime, period.EndTime)
}
}
func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC)
sent2 := sent1.Add(10 * time.Minute)
alerts := []indexedAlert{
{Index: 4, Alert: mapAlertRow(alertRow{AlertIndex: 4, AlertID: "a-4"}).Alert},
{Index: 9, Alert: mapAlertRow(alertRow{AlertIndex: 9, AlertID: "a-9"}).Alert},
}
references := []indexedAlertReference{
mapAlertReferenceRow(alertReferenceRow{AlertIndex: 4, Identifier: sql.NullString{String: "r1", Valid: true}, Sent: sql.NullTime{Time: sent1, Valid: true}}),
mapAlertReferenceRow(alertReferenceRow{AlertIndex: 4, Identifier: sql.NullString{String: "r2", Valid: true}, Sent: sql.NullTime{Time: sent2, Valid: true}}),
mapAlertReferenceRow(alertReferenceRow{AlertIndex: 9, Identifier: sql.NullString{String: "r9", Valid: true}}),
}
out := attachAlertReferences(alerts, references)
if len(out) != 2 {
t.Fatalf("expected 2 alerts, got %d", len(out))
}
if len(out[0].References) != 2 {
t.Fatalf("expected first alert to have 2 references, got %d", len(out[0].References))
}
if out[0].References[0].Identifier != "r1" || out[0].References[1].Identifier != "r2" {
t.Fatalf("unexpected first alert reference order: %+v", out[0].References)
}
if len(out[1].References) != 1 || out[1].References[0].Identifier != "r9" {
t.Fatalf("unexpected second alert references: %+v", out[1].References)
}
}

View File

@@ -0,0 +1,65 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"github.com/jackc/pgx/v5/pgxpool"
)
type AlertRepository struct {
pool *pgxpool.Pool
}
func NewAlertRepository(pool *pgxpool.Pool) *AlertRepository {
return &AlertRepository{pool: pool}
}
func (r *AlertRepository) ListCurrentAlerts(ctx context.Context) ([]ports.AlertRecord, error) {
rows, err := r.pool.Query(ctx, queryCurrentAlerts)
if err != nil {
return nil, fmt.Errorf("query current alerts: %w", err)
}
defer rows.Close()
out := make([]ports.AlertRecord, 0)
for rows.Next() {
var effective sql.NullTime
var expires sql.NullTime
var severity sql.NullString
var event sql.NullString
var headline sql.NullString
var instruction sql.NullString
var description sql.NullString
if err := rows.Scan(
&effective,
&expires,
&severity,
&event,
&headline,
&instruction,
&description,
); err != nil {
return nil, fmt.Errorf("scan current alert row: %w", err)
}
out = append(out, ports.AlertRecord{
Effective: ptrTime(effective),
Expires: ptrTime(expires),
Severity: ptrString(severity),
Event: ptrString(event),
Headline: ptrString(headline),
Instruction: ptrString(instruction),
Description: ptrString(description),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate current alert rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,119 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"github.com/jackc/pgx/v5/pgxpool"
)
type ForecastRepository struct {
pool *pgxpool.Pool
}
func NewForecastRepository(pool *pgxpool.Pool) *ForecastRepository {
return &ForecastRepository{pool: pool}
}
func (r *ForecastRepository) ListForecastPeriodsAt(ctx context.Context, ts time.Time, limit int) ([]ports.ForecastPeriodMetric, error) {
rows, err := r.pool.Query(ctx, queryForecastPeriodsAt, ts, limit)
if err != nil {
return nil, fmt.Errorf("query forecast periods at timestamp: %w", err)
}
defer rows.Close()
out := make([]ports.ForecastPeriodMetric, 0)
for rows.Next() {
var row ports.ForecastPeriodMetric
var name sql.NullString
var isDay sql.NullBool
var conditionText sql.NullString
var providerRawDescription sql.NullString
var textDescription sql.NullString
var detailedText sql.NullString
var iconURL sql.NullString
var temperature sql.NullFloat64
var temperatureMin sql.NullFloat64
var temperatureMax sql.NullFloat64
var dewpoint sql.NullFloat64
var humidity sql.NullFloat64
var windDirection sql.NullFloat64
var windSpeed sql.NullFloat64
var windGust sql.NullFloat64
var pressure sql.NullFloat64
var visibility sql.NullFloat64
var apparent sql.NullFloat64
var cloudCover sql.NullFloat64
var precipProbability sql.NullFloat64
var precipAmount sql.NullFloat64
var snowfallDepth sql.NullFloat64
var uvIndex sql.NullFloat64
if err := rows.Scan(
&row.PeriodIndex,
&row.StartTime,
&row.EndTime,
&name,
&isDay,
&row.ConditionCode,
&conditionText,
&providerRawDescription,
&textDescription,
&detailedText,
&iconURL,
&temperature,
&temperatureMin,
&temperatureMax,
&dewpoint,
&humidity,
&windDirection,
&windSpeed,
&windGust,
&pressure,
&visibility,
&apparent,
&cloudCover,
&precipProbability,
&precipAmount,
&snowfallDepth,
&uvIndex,
); err != nil {
return nil, fmt.Errorf("scan forecast period row: %w", err)
}
row.Name = ptrString(name)
row.IsDay = ptrBool(isDay)
row.ConditionText = ptrString(conditionText)
row.ProviderRawDescription = ptrString(providerRawDescription)
row.TextDescription = ptrString(textDescription)
row.DetailedText = ptrString(detailedText)
row.IconURL = ptrString(iconURL)
row.TemperatureC = ptrFloat64(temperature)
row.TemperatureCMin = ptrFloat64(temperatureMin)
row.TemperatureCMax = ptrFloat64(temperatureMax)
row.DewpointC = ptrFloat64(dewpoint)
row.RelativeHumidityPercent = ptrFloat64(humidity)
row.WindDirectionDegrees = ptrFloat64(windDirection)
row.WindSpeedKmh = ptrFloat64(windSpeed)
row.WindGustKmh = ptrFloat64(windGust)
row.BarometricPressurePa = ptrFloat64(pressure)
row.VisibilityMeters = ptrFloat64(visibility)
row.ApparentTemperatureC = ptrFloat64(apparent)
row.CloudCoverPercent = ptrFloat64(cloudCover)
row.ProbabilityOfPrecipitationPercent = ptrFloat64(precipProbability)
row.PrecipitationAmountMm = ptrFloat64(precipAmount)
row.SnowfallDepthMm = ptrFloat64(snowfallDepth)
row.UVIndex = ptrFloat64(uvIndex)
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate forecast period rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,184 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"github.com/jackc/pgx/v5/pgxpool"
)
type ObservationRepository struct {
pool *pgxpool.Pool
}
func NewObservationRepository(pool *pgxpool.Pool) *ObservationRepository {
return &ObservationRepository{pool: pool}
}
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, 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.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) 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 recent observations: %w", err)
}
defer rows.Close()
out := make([]ports.ObservationRecordMetric, 0, count)
eventIDs := make([]string, 0, count)
for rows.Next() {
var eventID string
var stationID sql.NullString
var stationName sql.NullString
var timestamp time.Time
var conditionCode int
var isDay sql.NullBool
var textDescription 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,
&stationName,
&timestamp,
&conditionCode,
&isDay,
&textDescription,
&temperature,
&dewpoint,
&windDirection,
&windSpeed,
&windGust,
&pressure,
&visibility,
&relativeHumidity,
&apparent,
); err != nil {
return nil, fmt.Errorf("scan recent observation row: %w", err)
}
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 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) 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 present weather: %w", err)
}
defer rows.Close()
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(&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 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 present weather rows: %w", err)
}
return out, nil
}
func windowMinutes(window time.Duration) int {
return int(window / time.Minute)
}

View File

@@ -0,0 +1,130 @@
package postgres
const (
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,
AVG(dewpoint_c) AS dewpoint_c,
AVG(relative_humidity_percent) AS relative_humidity_percent,
AVG(wind_speed_kmh) AS wind_speed_kmh,
CASE
WHEN atan2d(
avg(sind(wind_direction_degrees)),
avg(cosd(wind_direction_degrees))
) < 0
THEN atan2d(
avg(sind(wind_direction_degrees)),
avg(cosd(wind_direction_degrees))
) + 360.0
ELSE atan2d(
avg(sind(wind_direction_degrees)),
avg(cosd(wind_direction_degrees))
)
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;
`
queryRecentObservations = `
SELECT
event_id,
station_id,
station_name,
observed_at,
condition_code,
is_day,
text_description,
temperature_c,
dewpoint_c,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
relative_humidity_percent,
apparent_temperature_c
FROM observations
ORDER BY observed_at DESC
LIMIT $1;
`
queryObservationPresentWeather = `
SELECT
event_id,
weather_index,
raw_text
FROM observation_present_weather
WHERE event_id = ANY($1)
ORDER BY event_id ASC, weather_index ASC;
`
queryForecastPeriodsAt = `
SELECT
period_index,
start_time,
end_time,
name,
is_day,
condition_code,
condition_text,
provider_raw_description,
text_description,
detailed_text,
icon_url,
temperature_c,
temperature_c_min,
temperature_c_max,
dewpoint_c,
relative_humidity_percent,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
apparent_temperature_c,
cloud_cover_percent,
probability_of_precipitation_percent,
precipitation_amount_mm,
snowfall_depth_mm,
uv_index
FROM forecast_periods
WHERE start_time < $1
AND end_time > $1
ORDER BY period_index ASC, start_time DESC
LIMIT $2;
`
queryCurrentAlerts = `
SELECT
effective,
expires,
severity,
event,
headline,
instruction,
description
FROM alerts
WHERE expires > CURRENT_TIMESTAMP;
`
)

View File

@@ -0,0 +1,48 @@
package postgres
import (
"strings"
"testing"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
func TestQueriesUseParametersNotHardcodedValues(t *testing.T) {
if !strings.Contains(queryCurrentConditionsSummary, "make_interval(mins => $1)") {
t.Fatalf("current conditions summary query must use $1 minutes parameter")
}
if strings.Contains(queryCurrentConditionsSummary, "30 minutes") {
t.Fatalf("current conditions summary query should not hardcode 30 minutes")
}
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(queryRecentObservations, "LIMIT $1") {
t.Fatalf("recent observations query must use parameterized limit")
}
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") {
t.Fatalf("forecast query must use parameterized limit")
}
if strings.Contains(queryForecastPeriodsAt, "LIMIT 5") {
t.Fatalf("forecast query should not hardcode limit=5")
}
}
func TestWindowMinutesUsesObservationWindowConstant(t *testing.T) {
got := windowMinutes(constants.ObservationWindow)
if got != 30 {
t.Fatalf("expected 30 minutes from constants.ObservationWindow, got %d", got)
}
}

View File

@@ -0,0 +1,46 @@
package postgres
import (
"database/sql"
"time"
)
func ptrFloat64(v sql.NullFloat64) *float64 {
if !v.Valid {
return nil
}
f := v.Float64
return &f
}
func ptrString(v sql.NullString) *string {
if !v.Valid {
return nil
}
s := v.String
return &s
}
func ptrBool(v sql.NullBool) *bool {
if !v.Valid {
return nil
}
b := v.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
}
t := v.Time
return &t
}

View File

@@ -0,0 +1,57 @@
package alerts
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
)
type Service struct {
repo ports.AlertRepository
}
func NewService(repo ports.AlertRepository) *Service {
return &Service{repo: repo}
}
type Response struct {
Alerts []AlertResponse `json:"alerts"`
}
type AlertResponse struct {
Effective *time.Time `json:"effective,omitempty"`
Expires *time.Time `json:"expires,omitempty"`
Severity *string `json:"severity,omitempty"`
Event *string `json:"event,omitempty"`
Headline *string `json:"headline,omitempty"`
Instruction *string `json:"instruction,omitempty"`
Description *string `json:"description,omitempty"`
}
func (s *Service) GetCurrent(ctx context.Context) (Response, error) {
if s == nil {
return Response{}, fmt.Errorf("alerts service is nil")
}
rows, err := s.repo.ListCurrentAlerts(ctx)
if err != nil {
return Response{}, err
}
alerts := make([]AlertResponse, 0, len(rows))
for _, row := range rows {
alerts = append(alerts, AlertResponse{
Effective: row.Effective,
Expires: row.Expires,
Severity: row.Severity,
Event: row.Event,
Headline: row.Headline,
Instruction: row.Instruction,
Description: row.Description,
})
}
return Response{Alerts: alerts}, nil
}

View File

@@ -0,0 +1,82 @@
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"`
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,
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

@@ -0,0 +1,131 @@
package forecasts
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"
)
type Service struct {
repo ports.ForecastRepository
registry *units.Registry
limit int
}
func NewService(repo ports.ForecastRepository, registry *units.Registry, limit int) *Service {
return &Service{repo: repo, registry: registry, limit: limit}
}
type Response struct {
Timestamp time.Time `json:"timestamp"`
Periods []PeriodResponse `json:"periods"`
}
type PeriodResponse struct {
PeriodIndex int `json:"periodIndex"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Name *string `json:"name,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
ConditionCode int `json:"conditionCode"`
ConditionText *string `json:"conditionText,omitempty"`
ProviderRawDescription *string `json:"providerRawDescription,omitempty"`
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"`
PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"`
SnowfallDepthMm *float64 `json:"snowfallDepthMm,omitempty"`
UVIndex *float64 `json:"uvIndex,omitempty"`
}
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
}
periods := make([]PeriodResponse, 0, len(periodsMetric))
for _, p := range periodsMetric {
item := PeriodResponse{
PeriodIndex: p.PeriodIndex,
StartTime: p.StartTime,
EndTime: p.EndTime,
Name: p.Name,
IsDay: p.IsDay,
ConditionCode: p.ConditionCode,
ConditionText: p.ConditionText,
ProviderRawDescription: p.ProviderRawDescription,
TextDescription: p.TextDescription,
DetailedText: p.DetailedText,
IconURL: p.IconURL,
RelativeHumidityPercent: p.RelativeHumidityPercent,
WindDirectionDegrees: p.WindDirectionDegrees,
BarometricPressurePa: p.BarometricPressurePa,
VisibilityMeters: p.VisibilityMeters,
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{
Timestamp: ts,
Periods: periods,
}, nil
}

View File

@@ -0,0 +1,109 @@
package observations
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"
)
type Service struct {
repo ports.ObservationRepository
registry *units.Registry
}
func NewService(repo ports.ObservationRepository, registry *units.Registry) *Service {
return &Service{repo: repo, registry: registry}
}
type Response struct {
Observations []ObservationResponse `json:"observations"`
}
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 PresentWeatherResponse struct {
Raw map[string]any `json:"raw,omitempty"`
}
func (s *Service) GetRecent(ctx context.Context, count int, unitSystem string) (Response, error) {
if s == nil {
return Response{}, fmt.Errorf("observations service is nil")
}
converter, err := s.registry.Resolve(unitSystem)
if err != nil {
return Response{}, err
}
records, err := s.repo.ListRecentObservations(ctx, count)
if err != nil {
return Response{}, 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)
}
return Response{Observations: out}, nil
}

View File

@@ -0,0 +1,62 @@
package units
import (
"math"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
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
}
f := round(constants.CelsiusToFahrenheit(*celsius), constants.TempFahrenheitPrecision)
return &f
}
func (USConverter) SpeedKmhToOutput(kmh *float64) *float64 {
if kmh == nil {
return nil
}
mph := round(constants.KmhToMph(*kmh), constants.WindMphPrecision)
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

@@ -0,0 +1,72 @@
package units
import "testing"
func TestUSConverterTemperatureCToOutput(t *testing.T) {
c := USConverter{}
if got := c.TemperatureCToOutput(nil); got != nil {
t.Fatalf("expected nil for nil input")
}
zeroC := 0.0
got := c.TemperatureCToOutput(&zeroC)
if got == nil || *got != 32.0 {
t.Fatalf("expected 32.0F, got %v", got)
}
v := 20.56 // 69.008F -> 69.0 at precision 1
got = c.TemperatureCToOutput(&v)
if got == nil || *got != 69.0 {
t.Fatalf("expected 69.0F, got %v", got)
}
}
func TestUSConverterSpeedKmhToOutput(t *testing.T) {
c := USConverter{}
if got := c.SpeedKmhToOutput(nil); got != nil {
t.Fatalf("expected nil for nil input")
}
v := 10.0 // 6.21371 mph -> 6.2 at precision 1
got := c.SpeedKmhToOutput(&v)
if got == nil || *got != 6.2 {
t.Fatalf("expected 6.2 mph, got %v", got)
}
exact := 16.09344 // exact 10 mph
got = c.SpeedKmhToOutput(&exact)
if got == nil || *got != 10.0 {
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

@@ -0,0 +1,94 @@
package ports
import (
"context"
"time"
)
type ObservationCurrentConditionsMetric struct {
TemperatureC *float64
ApparentTemperatureC *float64
DewpointC *float64
RelativeHumidity *float64
WindSpeedKmh *float64
WindDirectionDegrees *float64
ConditionCode *int
IsDay *bool
}
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 {
PeriodIndex int
StartTime time.Time
EndTime time.Time
Name *string
IsDay *bool
ConditionCode int
ConditionText *string
ProviderRawDescription *string
TextDescription *string
DetailedText *string
IconURL *string
TemperatureC *float64
TemperatureCMin *float64
TemperatureCMax *float64
DewpointC *float64
RelativeHumidityPercent *float64
WindDirectionDegrees *float64
WindSpeedKmh *float64
WindGustKmh *float64
BarometricPressurePa *float64
VisibilityMeters *float64
ApparentTemperatureC *float64
CloudCoverPercent *float64
ProbabilityOfPrecipitationPercent *float64
PrecipitationAmountMm *float64
SnowfallDepthMm *float64
UVIndex *float64
}
type AlertRecord struct {
Effective *time.Time
Expires *time.Time
Severity *string
Event *string
Headline *string
Instruction *string
Description *string
}
type ObservationRepository interface {
GetCurrentConditionsSummary(ctx context.Context, window time.Duration) (ObservationCurrentConditionsMetric, error)
ListRecentObservations(ctx context.Context, count int) ([]ObservationRecordMetric, error)
}
type ForecastRepository interface {
ListForecastPeriodsAt(ctx context.Context, ts time.Time, limit int) ([]ForecastPeriodMetric, error)
}
type AlertRepository interface {
ListCurrentAlerts(ctx context.Context) ([]AlertRecord, error)
}

View File

@@ -1,35 +0,0 @@
package core
import (
"context"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Repository defines outbound data access used by weatherapi use cases.
type Repository interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error)
}
// Service provides weather read use-cases.
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
return s.repo.LatestObservation(ctx)
}
func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return s.repo.LatestHourlyForecast(ctx)
}
func (s *Service) LatestActiveAlerts(ctx context.Context) (*model.WeatherAlertRun, error) {
return s.repo.LatestActiveAlerts(ctx)
}

View File

@@ -0,0 +1,120 @@
package config
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Config holds weatherapi datasource configuration.
type Config struct {
Databases []DatabaseConfig
}
type DatabaseConfig struct {
Name string `yaml:"name"`
Driver string `yaml:"driver"`
Params DatabaseParams `yaml:"params"`
}
type DatabaseParams struct {
URI string `yaml:"uri"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
type configWrapper struct {
Databases []DatabaseConfig `yaml:"databases"`
}
func Load(path string) (*Config, error) {
if strings.TrimSpace(path) == "" {
path = "config.yml"
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config.Load: read %q: %w", path, err)
}
var list []DatabaseConfig
if err := decodeStrict(raw, &list); err == nil && len(list) > 0 {
cfg := &Config{Databases: list}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
var wrapped configWrapper
if err := decodeStrict(raw, &wrapped); err != nil {
return nil, fmt.Errorf("config.Load: parse YAML %q: %w", path, err)
}
cfg := &Config{Databases: wrapped.Databases}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func decodeStrict(raw []byte, out any) error {
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
dec.KnownFields(true)
if err := dec.Decode(out); err != nil {
return err
}
var extra any
if err := dec.Decode(&extra); err == nil {
return fmt.Errorf("contains multiple YAML documents; expected exactly one")
}
return nil
}
func (c *Config) Validate() error {
if c == nil {
return fmt.Errorf("config validation failed: config is nil")
}
if len(c.Databases) == 0 {
return fmt.Errorf("config validation failed: no databases configured")
}
seen := map[string]struct{}{}
for i, db := range c.Databases {
path := fmt.Sprintf("databases[%d]", i)
if strings.TrimSpace(db.Name) == "" {
return fmt.Errorf("config validation failed: %s.name is required", path)
}
if _, ok := seen[db.Name]; ok {
return fmt.Errorf("config validation failed: %s.name %q is duplicated", path, db.Name)
}
seen[db.Name] = struct{}{}
if strings.TrimSpace(db.Driver) == "" {
return fmt.Errorf("config validation failed: %s.driver is required", path)
}
if strings.TrimSpace(db.Params.URI) == "" {
return fmt.Errorf("config validation failed: %s.params.uri is required", path)
}
if strings.TrimSpace(db.Params.Username) == "" {
return fmt.Errorf("config validation failed: %s.params.username is required", path)
}
if strings.TrimSpace(db.Params.Password) == "" {
return fmt.Errorf("config validation failed: %s.params.password is required", path)
}
}
return nil
}
func (c *Config) FindDatabase(name string) (DatabaseConfig, bool) {
for _, db := range c.Databases {
if db.Name == name {
return db, true
}
}
return DatabaseConfig{}, false
}

View File

@@ -0,0 +1,53 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadRootList(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
content := `
- name: weatherdb
driver: postgres
params:
uri: postgres://weatherdb:5432/weatherdb?sslmode=disable
username: weatherdb
password: weatherdb
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if len(cfg.Databases) != 1 {
t.Fatalf("expected one database, got %d", len(cfg.Databases))
}
if cfg.Databases[0].Name != "weatherdb" {
t.Fatalf("unexpected db name %q", cfg.Databases[0].Name)
}
}
func TestLoadMissingRequiredFieldFails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
content := `
- name: weatherdb
driver: postgres
params:
username: weatherdb
password: weatherdb
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp config: %v", err)
}
if _, err := Load(path); err == nil {
t.Fatalf("expected validation error for missing params.uri")
}
}

View File

@@ -0,0 +1,42 @@
package constants
import "time"
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
// 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"
)
var SupportedTimestampLayouts = []string{
time.RFC3339Nano,
time.RFC3339,
}
const (
TempFahrenheitPrecision = 1
WindMphPrecision = 1
KmhPerMph = 1.609344
MphPerKmh = 1 / KmhPerMph
)

View File

@@ -0,0 +1,17 @@
package constants
func CelsiusToFahrenheit(c float64) float64 {
return c*9.0/5.0 + 32.0
}
func FahrenheitToCelsius(f float64) float64 {
return (f - 32.0) * 5.0 / 9.0
}
func KmhToMph(kmh float64) float64 {
return kmh * MphPerKmh
}
func MphToKmh(mph float64) float64 {
return mph * KmhPerMph
}

View File

@@ -0,0 +1,71 @@
package postgres
import (
"context"
"fmt"
"net/url"
"strings"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/config"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/datasource"
"github.com/jackc/pgx/v5/pgxpool"
)
type Factory struct{}
func (Factory) Driver() string {
return "postgres"
}
func (Factory) Open(ctx context.Context, cfg config.DatabaseConfig) (datasource.DataSource, error) {
dsn, err := buildDSN(cfg.Params.URI, cfg.Params.Username, cfg.Params.Password)
if err != nil {
return nil, fmt.Errorf("open postgres datasource %q: %w", cfg.Name, err)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("open postgres datasource %q: %w", cfg.Name, err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("open postgres datasource %q: ping: %w", cfg.Name, err)
}
return &DataSource{pool: pool}, nil
}
type DataSource struct {
pool *pgxpool.Pool
}
func (d *DataSource) Pool() *pgxpool.Pool {
if d == nil {
return nil
}
return d.pool
}
func (d *DataSource) Close() {
if d == nil || d.pool == nil {
return
}
d.pool.Close()
}
func buildDSN(uri, username, password string) (string, error) {
u, err := url.Parse(strings.TrimSpace(uri))
if err != nil {
return "", fmt.Errorf("invalid params.uri: %w", err)
}
if strings.TrimSpace(u.Scheme) == "" {
return "", fmt.Errorf("invalid params.uri: missing scheme")
}
if strings.TrimSpace(u.Host) == "" {
return "", fmt.Errorf("invalid params.uri: missing host")
}
u.User = url.UserPassword(strings.TrimSpace(username), strings.TrimSpace(password))
return u.String(), nil
}

View File

@@ -0,0 +1,64 @@
package datasource
import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/config"
)
// DataSource is a generic opened datasource handle.
type DataSource interface {
Close()
}
// Factory constructs datasource handles for a specific driver.
type Factory interface {
Driver() string
Open(ctx context.Context, cfg config.DatabaseConfig) (DataSource, error)
}
// Registry maps driver names to datasource factories.
type Registry struct {
factories map[string]Factory
}
func NewRegistry() *Registry {
return &Registry{factories: map[string]Factory{}}
}
func (r *Registry) Register(factory Factory) error {
if factory == nil {
return fmt.Errorf("register datasource factory: factory is nil")
}
driver := normalizeDriver(factory.Driver())
if driver == "" {
return fmt.Errorf("register datasource factory: factory driver is empty")
}
if _, exists := r.factories[driver]; exists {
return fmt.Errorf("register datasource factory: driver %q already registered", driver)
}
r.factories[driver] = factory
return nil
}
func (r *Registry) Open(ctx context.Context, cfg config.DatabaseConfig) (DataSource, error) {
if r == nil {
return nil, fmt.Errorf("open datasource: registry is nil")
}
driver := normalizeDriver(cfg.Driver)
factory, ok := r.factories[driver]
if !ok {
return nil, fmt.Errorf("open datasource: unsupported driver %q", cfg.Driver)
}
ds, err := factory.Open(ctx, cfg)
if err != nil {
return nil, err
}
return ds, nil
}
func normalizeDriver(driver string) string {
return strings.ToLower(strings.TrimSpace(driver))
}

View File

@@ -0,0 +1,30 @@
package timeparse
import (
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
func ParseTimestamp(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, fmt.Errorf("timestamp is required")
}
var lastErr error
for _, layout := range constants.SupportedTimestampLayouts {
ts, err := time.Parse(layout, raw)
if err == nil {
return ts, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("invalid timestamp")
}
return time.Time{}, fmt.Errorf("timestamp must be RFC3339 (example: 2026-03-17T12:30:00Z): %w", lastErr)
}

View File

@@ -0,0 +1,29 @@
package timeparse
import "testing"
func TestParseTimestamp(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{name: "rfc3339", input: "2026-03-17T12:30:00Z", wantErr: false},
{name: "rfc3339nano", input: "2026-03-17T12:30:00.123456789Z", wantErr: false},
{name: "missing timezone", input: "2026-03-17T12:30:00", wantErr: true},
{name: "invalid", input: "not-a-time", wantErr: true},
{name: "empty", input: "", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ParseTimestamp(tc.input)
if tc.wantErr && err == nil {
t.Fatalf("expected error")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}

View File

@@ -1,20 +0,0 @@
{{- if .Data -}}
Active Alerts
As Of: {{.Data.AsOf}}
Alerts: {{len .Data.Alerts}}
{{- range $i, $alert := .Data.Alerts}}
[{{$i}}] {{$alert.ID}}
{{- if $alert.Headline}}
Headline: {{$alert.Headline}}
{{- end}}
{{- if $alert.Severity}}
Severity: {{$alert.Severity}}
{{- end}}
{{- if $alert.Expires}}
Expires: {{$alert.Expires}}
{{- end}}
{{- end}}
{{- else -}}
No active alerts data available.
{{- end}}

View File

@@ -1,17 +0,0 @@
{{- if .Data -}}
Hourly Forecast
Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}}
Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}}
Issued At: {{.Data.IssuedAt}}
Periods: {{len .Data.Periods}}
{{- range $i, $period := .Data.Periods}}
[{{$i}}] {{$period.StartTime}} -> {{$period.EndTime}}
Condition Code: {{$period.ConditionCode}}
{{- if $period.TextDescription}}
Summary: {{$period.TextDescription}}
{{- end}}
{{- end}}
{{- else -}}
No hourly forecast data available.
{{- end}}

View File

@@ -1,12 +0,0 @@
{{- if .Data -}}
Observation
Station ID: {{if .Data.StationID}}{{.Data.StationID}}{{else}}n/a{{end}}
Station Name: {{if .Data.StationName}}{{.Data.StationName}}{{else}}n/a{{end}}
Timestamp: {{.Data.Timestamp}}
Condition Code: {{.Data.ConditionCode}}
{{- if .Data.TextDescription}}
Summary: {{.Data.TextDescription}}
{{- end}}
{{- else -}}
No observation data available.
{{- end}}

Binary file not shown.