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
87 changed files with 2620 additions and 4775 deletions

View File

@@ -5,18 +5,10 @@ when:
steps: steps:
- name: build-and-push-image - name: build-and-push-image
image: harbor.maximumdirect.net/proxy-dockerhub/woodpeckerci/plugin-kaniko image: harbor.maximumdirect.net/proxy-dockerhub/woodpeckerci/plugin-kaniko
environment:
GITEA_USER:
from_secret: GITEA_USER
GITEA_TOKEN:
from_secret: GITEA_TOKEN
settings: settings:
registry: harbor.maximumdirect.net registry: harbor.maximumdirect.net
repo: build/weatherapi repo: build/weatherapi
auto_tag: true auto_tag: true
build_args_from_env:
- GITEA_USER
- GITEA_TOKEN
username: username:
from_secret: HARBOR_ROBOT_USER from_secret: HARBOR_ROBOT_USER
password: password:

View File

@@ -1,8 +1,6 @@
# syntax=docker/dockerfile:1.6 # syntax=docker/dockerfile:1.6
ARG GO_VERSION=1.25 ARG GO_VERSION=1.25
ARG GITEA_USER
ARG GITEA_TOKEN
############################ ############################
# Build stage # Build stage
@@ -10,10 +8,6 @@ ARG GITEA_TOKEN
FROM harbor.maximumdirect.net/proxy-dockerhub/golang:${GO_VERSION}-bookworm AS build FROM harbor.maximumdirect.net/proxy-dockerhub/golang:${GO_VERSION}-bookworm AS build
WORKDIR /src WORKDIR /src
ARG GITEA_USER
ARG GITEA_TOKEN
ENV GOPRIVATE=gitea.maximumdirect.net/ejr/* \
GONOSUMDB=gitea.maximumdirect.net/ejr/*
# Install baseline packages # Install baseline packages
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -23,13 +17,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Cache dependencies first # Cache dependencies first
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
git config --global url."https://${GITEA_USER}:${GITEA_TOKEN}@gitea.maximumdirect.net/".insteadOf "https://gitea.maximumdirect.net/" && \ go mod download
go mod download && go mod verify && \
rm -f /root/.gitconfig
# Copy the rest of the source # Copy the rest of the source
COPY . . 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) # Default to a static build (no CGO)
# If errors, can build with: --build-arg CGO_ENABLED=1 # If errors, can build with: --build-arg CGO_ENABLED=1
ARG CGO_ENABLED=0 ARG CGO_ENABLED=0
@@ -42,13 +38,10 @@ ENV CGO_ENABLED=${CGO_ENABLED} \
# Run tests before building the final binary # Run tests before building the final binary
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/go-build \
git config --global url."https://${GITEA_USER}:${GITEA_TOKEN}@gitea.maximumdirect.net/".insteadOf "https://gitea.maximumdirect.net/" && \
go test ./... go test ./...
# Build the cmd entrypoint # Build the cmd entrypoint
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/go-build \
git config --global url."https://${GITEA_USER}:${GITEA_TOKEN}@gitea.maximumdirect.net/".insteadOf "https://gitea.maximumdirect.net/" && \
go build \ go build \
-trimpath \ -trimpath \
-ldflags="-s -w" \ -ldflags="-s -w" \
@@ -78,8 +71,6 @@ RUN useradd \
# Copy the binary # Copy the binary
COPY --chown=weatherapi:weatherapi --from=build /out/weatherapi /weatherapi/weatherapi COPY --chown=weatherapi:weatherapi --from=build /out/weatherapi /weatherapi/weatherapi
COPY --chown=weatherapi:weatherapi config.yml /weatherapi/config.yml
COPY --chown=weatherapi:weatherapi templates /weatherapi/templates
USER weatherapi USER weatherapi

View File

@@ -1,31 +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 ## Endpoints
- `GET /observations`
- `GET /conditions/current` - `GET /conditions/current`
- `GET /alerts/active` - `GET /observations?count=<1..100>&units=<us|metric>`
- `GET /discussion` - `GET /forecast?timestamp=<RFC3339 timestamp>`
- `GET /forecast/hourly` - `GET /alerts/current`
- `GET /forecast/hourly/today`
- `GET /forecast/hourly/tomorrow`
- `GET /forecast/narrative`
- `GET /forecast/narrative/today`
- `GET /forecast/narrative/tomorrow`
## Query Parameters Default unit behavior:
- `/conditions/current`: US units
- `/observations`: metric units
- `/forecast`: US units
Shared weather query parameters: `/conditions/current`, `/observations`, and `/forecast` support `?units=us|metric`.
`/observations` defaults to `count=5` and validates `count` strictly in range `1..100`.
- `format` (`json`, `xml`, `text`) ## Run
- `units` (`metric`, `us`)
Forecast endpoint query parameters: ```bash
go run ./cmd/weatherapi -config ./config.yml -addr :8080
- `precision` (`0`-`2`) ```
Forecast and discussion endpoint query parameters:
- `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset)

View File

@@ -1,5 +1,3 @@
// main.go wires configuration, dependencies, and HTTP runtime startup.
// Layer: cmd/weatherapi executable composition root.
package main package main
import ( import (
@@ -7,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/app" "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 := app.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

16
go.mod
View File

@@ -3,9 +3,17 @@ module gitea.maximumdirect.net/ejr/weatherapi
go 1.25.5 go 1.25.5
require ( require (
gitea.maximumdirect.net/ejr/feedapi v0.1.0 gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2
gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3 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
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
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
)

41
go.sum
View File

@@ -1,10 +1,37 @@
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY= gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2 h1:GJdZ9x54HLTPidPGhD/dmq7efvGjBltK1ywCIRU1w6c=
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw= gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2/go.mod h1:P7rP7XftJjBFzNEIkAiX+z2YqXOtnZjy3BaF6kX+sO4=
gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3 h1:vH5p8zKiJ6D7JnbpA43iWKNEdgQg/VwKKaWlwF3AXXs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3/go.mod h1:YeHGpmJihwutT+2t8La8e2pPCusdxqiUNyZZOPqE33I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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,28 +0,0 @@
// alerts_endpoint.go defines the /alerts/active endpoint behavior.
// Layer: adapters/inbound/httpapi alerts route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func alertsDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/alerts/active",
bindQuery,
func(ctx context.Context, req queryRequest) (any, error) {
run, err := svc.LatestAlertRun(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.AlertsPayload(run, req.Units)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("alerts_active.txt.tmpl"),
)
}

View File

@@ -1,28 +0,0 @@
// conditions_endpoint.go defines the /conditions/current endpoint behavior.
// Layer: adapters/inbound/httpapi current-conditions route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func conditionsDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/conditions/current",
bindPrecisionQuery,
func(ctx context.Context, req precisionQueryRequest) (any, error) {
conditions, err := svc.CurrentConditions(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.CurrentConditionsPayload(conditions, req.Units, req.Precision)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("conditions_current.txt.tmpl"),
)
}

View File

@@ -1,28 +0,0 @@
// discussion_endpoint.go defines the /discussion endpoint behavior.
// Layer: adapters/inbound/httpapi discussion route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func discussionDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/discussion",
bindTimezoneQuery,
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
run, err := svc.LatestForecastDiscussion(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.DiscussionPayload(run, req.Units, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("discussion.txt.tmpl"),
)
}

View File

@@ -1,16 +0,0 @@
// endpoints.go registers all HTTP endpoint definitions for weatherapi.
// Layer: adapters/inbound/httpapi endpoint registry only.
package httpapi
import "gitea.maximumdirect.net/ejr/feedapi/endpoint"
func Definitions(svc Service) []endpoint.Definition {
defs := []endpoint.Definition{
observationDefinition(svc),
alertsDefinition(svc),
discussionDefinition(svc),
conditionsDefinition(svc),
}
defs = append(defs, forecastDefinitions(svc)...)
return defs
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
// forecast_endpoint.go defines forecast endpoint behavior.
// Layer: adapters/inbound/httpapi forecast route.
package httpapi
import (
"context"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type forecastDaySlice int
const (
forecastDaySliceAll forecastDaySlice = iota
forecastDaySliceToday
forecastDaySliceTomorrow
)
var forecastNow = time.Now
func forecastDefinitions(svc Service) []endpoint.Definition {
out := make([]endpoint.Definition, 0, 6)
out = append(out, forecastDefinitionSet(
"/forecast/hourly",
"forecast_hourly.txt.tmpl",
svc.LatestHourlyForecast,
)...)
out = append(out, forecastDefinitionSet(
"/forecast/narrative",
"forecast_narrative.txt.tmpl",
svc.LatestNarrativeForecast,
)...)
return out
}
func forecastDefinitionSet(
basePath string,
templateName string,
fetch func(context.Context) (*model.WeatherForecastRun, error),
) []endpoint.Definition {
return []endpoint.Definition{
forecastDefinition(basePath, forecastDaySliceAll, templateName, fetch),
forecastDefinition(basePath+"/today", forecastDaySliceToday, templateName, fetch),
forecastDefinition(basePath+"/tomorrow", forecastDaySliceTomorrow, templateName, fetch),
}
}
func forecastDefinition(
path string,
daySlice forecastDaySlice,
templateName string,
fetch func(context.Context) (*model.WeatherForecastRun, error),
) endpoint.Definition {
return endpoint.GET(
path,
bindForecastPrecisionQuery,
func(ctx context.Context, req precisionQueryRequest) (any, error) {
run, err := fetch(ctx)
if err != nil {
return nil, err
}
if daySlice != forecastDaySliceAll {
run = filterForecastRunByDaySlice(run, req.Timezone, daySlice)
}
return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate(templateName),
)
}
func filterForecastRunByDaySlice(run *model.WeatherForecastRun, tz *time.Location, daySlice forecastDaySlice) *model.WeatherForecastRun {
if run == nil {
return nil
}
loc := tz
if loc == nil {
loc = time.UTC
}
now := forecastNow().In(loc)
target := now
if daySlice == forecastDaySliceTomorrow {
target = target.AddDate(0, 0, 1)
}
year, month, day := target.Date()
periods := make([]model.WeatherForecastPeriod, 0, len(run.Periods))
for _, period := range run.Periods {
start := period.StartTime.In(loc)
y, m, d := start.Date()
if y == year && m == month && d == day {
periods = append(periods, period)
}
}
cloned := *run
cloned.Periods = periods
return &cloned
}

View File

@@ -1,28 +0,0 @@
// observations_endpoint.go defines the /observations endpoint behavior.
// Layer: adapters/inbound/httpapi observation route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func observationDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/observations",
bindPrecisionQuery,
func(ctx context.Context, req precisionQueryRequest) (any, error) {
obs, err := svc.LatestObservation(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.ObservationPayload(obs, req.Units, req.Precision)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("observations.txt.tmpl"),
)
}

View File

@@ -1,12 +0,0 @@
// alerts.go presents alert-run payloads.
// Layer: adapters/inbound/httpapi/presenter alerts payload mapping.
package presenter
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
func AlertsPayload(run *model.WeatherAlertRun, _ Units) any {
if run == nil {
return nil
}
return run
}

View File

@@ -1,54 +0,0 @@
// conditions.go presents current-conditions payloads in metric and US shapes.
// Layer: adapters/inbound/httpapi/presenter current-conditions payload mapping.
package presenter
import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
)
// CurrentConditionsResponse is the response shape for /conditions/current.
// Unit-bearing fields are populated according to the requested unit mode.
type CurrentConditionsResponse struct {
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
IsDayText string `json:"-" xml:"-"`
}
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
if conditions == nil {
return nil
}
out := CurrentConditionsResponse{
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
IsDay: copyBoolPtr(conditions.IsDay),
IsDayText: boolText(conditions.IsDay),
}
if units == UnitsUS {
out.TemperatureF = roundedPtr(celsiusToFahrenheitPtr(conditions.TemperatureC), precision)
out.ApparentTemperatureF = roundedPtr(celsiusToFahrenheitPtr(conditions.ApparentTemperatureC), precision)
out.DewpointF = roundedPtr(celsiusToFahrenheitPtr(conditions.DewpointC), precision)
out.WindSpeedMph = roundedPtr(scalePtr(conditions.WindSpeedKmh, kmhToMphFactor), precision)
return out
}
out.TemperatureC = roundedPtr(copyFloat64Ptr(conditions.TemperatureC), precision)
out.ApparentTemperatureC = roundedPtr(copyFloat64Ptr(conditions.ApparentTemperatureC), precision)
out.DewpointC = roundedPtr(copyFloat64Ptr(conditions.DewpointC), precision)
out.WindSpeedKmh = roundedPtr(copyFloat64Ptr(conditions.WindSpeedKmh), precision)
return out
}

View File

@@ -1,13 +0,0 @@
// constants.go defines unit conversion constants for payload presentation.
// Layer: adapters/inbound/httpapi/presenter conversion constants.
package presenter
const (
celsiusToFahrenheitScale = 9.0 / 5.0
celsiusToFahrenheitOffset = 32.0
kmhToMphFactor = 0.621371192237334
metersToMilesFactor = 0.000621371192237334
metersToFeetFactor = 3.280839895013123
paToInHgFactor = 0.000295299830714045
mmToInchesFactor = 0.03937007874015748
)

View File

@@ -1,38 +0,0 @@
// discussion.go presents forecast discussion payloads.
// Layer: adapters/inbound/httpapi/presenter discussion payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
out := model.WeatherForecastDiscussion{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
KeyMessages: append([]string(nil), run.KeyMessages...),
ShortTerm: copyDiscussionSection(run.ShortTerm, tz),
LongTerm: copyDiscussionSection(run.LongTerm, tz),
}
return &out
}
func copyDiscussionSection(in *model.WeatherForecastDiscussionSection, tz *time.Location) *model.WeatherForecastDiscussionSection {
if in == nil {
return nil
}
return &model.WeatherForecastDiscussionSection{
Qualifier: in.Qualifier,
IssuedAt: inLocationTimePtr(in.IssuedAt, tz),
Text: in.Text,
}
}

View File

@@ -1,134 +0,0 @@
// forecast.go presents forecast payloads in metric and US shapes.
// Layer: adapters/inbound/httpapi/presenter forecast payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// WeatherForecastRunUS is the US-customary response shape for forecasts.
type WeatherForecastRunUS struct {
LocationID string `json:"locationId,omitempty" xml:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty" xml:"locationName,omitempty"`
IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"`
Product model.ForecastProduct `json:"product" xml:"product"`
Latitude *float64 `json:"latitude,omitempty" xml:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty" xml:"longitude,omitempty"`
ElevationFeet *float64 `json:"elevationFeet,omitempty" xml:"elevationFeet,omitempty"`
Periods []WeatherForecastPeriodUS `json:"periods" xml:"periods"`
}
// WeatherForecastPeriodUS is the US-customary response shape for forecast periods.
type WeatherForecastPeriodUS struct {
StartTime time.Time `json:"startTime" xml:"startTime"`
EndTime time.Time `json:"endTime" xml:"endTime"`
Name string `json:"name,omitempty" xml:"name,omitempty"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty" xml:"temperatureFMin,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty" xml:"temperatureFMax,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty" xml:"cloudCoverPercent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty" xml:"probabilityOfPrecipitationPercent,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty" xml:"precipitationAmountIn,omitempty"`
SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty" xml:"snowfallDepthIn,omitempty"`
UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"`
}
func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, tz *time.Location) any {
if run == nil {
return nil
}
if units == UnitsUS {
out := WeatherForecastRunUS{
LocationID: run.LocationID,
LocationName: run.LocationName,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
Product: run.Product,
Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude),
ElevationFeet: roundedPtr(scalePtr(run.ElevationMeters, metersToFeetFactor), precision),
Periods: make([]WeatherForecastPeriodUS, 0, len(run.Periods)),
}
for _, p := range run.Periods {
out.Periods = append(out.Periods, WeatherForecastPeriodUS{
StartTime: inLocationTime(p.StartTime, tz),
EndTime: inLocationTime(p.EndTime, tz),
Name: p.Name,
IsDay: copyBoolPtr(p.IsDay),
ConditionCode: p.ConditionCode,
TextDescription: p.TextDescription,
TemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureC), precision),
TemperatureFMin: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMin), precision),
TemperatureFMax: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMax), precision),
DewpointF: roundedPtr(celsiusToFahrenheitPtr(p.DewpointC), precision),
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(p.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(p.WindDirectionDegrees), precision),
WindSpeedMph: roundedPtr(scalePtr(p.WindSpeedKmh, kmhToMphFactor), precision),
WindGustMph: roundedPtr(scalePtr(p.WindGustKmh, kmhToMphFactor), precision),
BarometricPressureInHg: roundedPtr(scalePtr(p.BarometricPressurePa, paToInHgFactor), precision),
VisibilityMiles: roundedPtr(scalePtr(p.VisibilityMeters, metersToMilesFactor), precision),
ApparentTemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.ApparentTemperatureC), precision),
CloudCoverPercent: roundedPtr(copyFloat64Ptr(p.CloudCoverPercent), precision),
ProbabilityOfPrecipitationPercent: roundedPtr(copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), precision),
PrecipitationAmountIn: roundedPtr(scalePtr(p.PrecipitationAmountMm, mmToInchesFactor), precision),
SnowfallDepthIn: roundedPtr(scalePtr(p.SnowfallDepthMM, mmToInchesFactor), precision),
UVIndex: roundedPtr(copyFloat64Ptr(p.UVIndex), precision),
})
}
return out
}
out := model.WeatherForecastRun{
LocationID: run.LocationID,
LocationName: run.LocationName,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
Product: run.Product,
Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude),
ElevationMeters: roundedPtr(copyFloat64Ptr(run.ElevationMeters), precision),
Periods: make([]model.WeatherForecastPeriod, 0, len(run.Periods)),
}
for _, p := range run.Periods {
out.Periods = append(out.Periods, model.WeatherForecastPeriod{
StartTime: inLocationTime(p.StartTime, tz),
EndTime: inLocationTime(p.EndTime, tz),
Name: p.Name,
IsDay: copyBoolPtr(p.IsDay),
ConditionCode: p.ConditionCode,
TextDescription: p.TextDescription,
TemperatureC: roundedPtr(copyFloat64Ptr(p.TemperatureC), precision),
TemperatureCMin: roundedPtr(copyFloat64Ptr(p.TemperatureCMin), precision),
TemperatureCMax: roundedPtr(copyFloat64Ptr(p.TemperatureCMax), precision),
DewpointC: roundedPtr(copyFloat64Ptr(p.DewpointC), precision),
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(p.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(p.WindDirectionDegrees), precision),
WindSpeedKmh: roundedPtr(copyFloat64Ptr(p.WindSpeedKmh), precision),
WindGustKmh: roundedPtr(copyFloat64Ptr(p.WindGustKmh), precision),
BarometricPressurePa: roundedPtr(copyFloat64Ptr(p.BarometricPressurePa), precision),
VisibilityMeters: roundedPtr(copyFloat64Ptr(p.VisibilityMeters), precision),
ApparentTemperatureC: roundedPtr(copyFloat64Ptr(p.ApparentTemperatureC), precision),
CloudCoverPercent: roundedPtr(copyFloat64Ptr(p.CloudCoverPercent), precision),
ProbabilityOfPrecipitationPercent: roundedPtr(copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), precision),
PrecipitationAmountMm: roundedPtr(copyFloat64Ptr(p.PrecipitationAmountMm), precision),
SnowfallDepthMM: roundedPtr(copyFloat64Ptr(p.SnowfallDepthMM), precision),
UVIndex: roundedPtr(copyFloat64Ptr(p.UVIndex), precision),
})
}
return &out
}

View File

@@ -1,89 +0,0 @@
// helpers.go contains shared pointer and scalar conversion helpers.
// Layer: adapters/inbound/httpapi/presenter helper functions.
package presenter
import (
"math"
"time"
)
func celsiusToFahrenheitPtr(v *float64) *float64 {
if v == nil {
return nil
}
out := (*v * celsiusToFahrenheitScale) + celsiusToFahrenheitOffset
return &out
}
func scalePtr(v *float64, factor float64) *float64 {
if v == nil {
return nil
}
out := *v * factor
return &out
}
func copyFloat64Ptr(v *float64) *float64 {
if v == nil {
return nil
}
out := *v
return &out
}
func copyBoolPtr(v *bool) *bool {
if v == nil {
return nil
}
out := *v
return &out
}
func copyTimePtr(v *time.Time) *time.Time {
if v == nil {
return nil
}
out := *v
return &out
}
func inLocationTime(v time.Time, loc *time.Location) time.Time {
if loc == nil {
return v
}
return v.In(loc)
}
func inLocationTimePtr(v *time.Time, loc *time.Location) *time.Time {
if v == nil {
return nil
}
out := inLocationTime(*v, loc)
return &out
}
func boolText(v *bool) string {
if v == nil {
return ""
}
if *v {
return "true"
}
return "false"
}
func roundedPtr(v *float64, precision int) *float64 {
if v == nil {
return nil
}
out := roundFloat(*v, precision)
return &out
}
func roundFloat(v float64, precision int) float64 {
if precision <= 0 {
return math.Round(v)
}
factor := math.Pow10(precision)
return math.Round(v*factor) / factor
}

View File

@@ -1,76 +0,0 @@
// observation.go presents observation payloads in metric and US shapes.
// Layer: adapters/inbound/httpapi/presenter observation payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// WeatherObservationUS is the US-customary response shape for observations.
type WeatherObservationUS struct {
StationID string `json:"stationId,omitempty" xml:"stationId,omitempty"`
StationName string `json:"stationName,omitempty" xml:"stationName,omitempty"`
Timestamp time.Time `json:"timestamp" xml:"timestamp"`
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
PresentWeather []model.PresentWeather `json:"presentWeather,omitempty" xml:"presentWeather,omitempty"`
}
func ObservationPayload(obs *model.WeatherObservation, units Units, precision int) any {
if obs == nil {
return nil
}
if units == UnitsUS {
converted := WeatherObservationUS{
StationID: obs.StationID,
StationName: obs.StationName,
Timestamp: obs.Timestamp,
ConditionCode: obs.ConditionCode,
IsDay: copyBoolPtr(obs.IsDay),
TextDescription: obs.TextDescription,
TemperatureF: roundedPtr(celsiusToFahrenheitPtr(obs.TemperatureC), precision),
DewpointF: roundedPtr(celsiusToFahrenheitPtr(obs.DewpointC), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(obs.WindDirectionDegrees), precision),
WindSpeedMph: roundedPtr(scalePtr(obs.WindSpeedKmh, kmhToMphFactor), precision),
WindGustMph: roundedPtr(scalePtr(obs.WindGustKmh, kmhToMphFactor), precision),
BarometricPressureInHg: roundedPtr(scalePtr(obs.BarometricPressurePa, paToInHgFactor), precision),
VisibilityMiles: roundedPtr(scalePtr(obs.VisibilityMeters, metersToMilesFactor), precision),
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(obs.RelativeHumidityPercent), precision),
ApparentTemperatureF: roundedPtr(celsiusToFahrenheitPtr(obs.ApparentTemperatureC), precision),
PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...),
}
return converted
}
rounded := model.WeatherObservation{
StationID: obs.StationID,
StationName: obs.StationName,
Timestamp: obs.Timestamp,
ConditionCode: obs.ConditionCode,
IsDay: copyBoolPtr(obs.IsDay),
TextDescription: obs.TextDescription,
TemperatureC: roundedPtr(copyFloat64Ptr(obs.TemperatureC), precision),
DewpointC: roundedPtr(copyFloat64Ptr(obs.DewpointC), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(obs.WindDirectionDegrees), precision),
WindSpeedKmh: roundedPtr(copyFloat64Ptr(obs.WindSpeedKmh), precision),
WindGustKmh: roundedPtr(copyFloat64Ptr(obs.WindGustKmh), precision),
BarometricPressurePa: roundedPtr(copyFloat64Ptr(obs.BarometricPressurePa), precision),
VisibilityMeters: roundedPtr(copyFloat64Ptr(obs.VisibilityMeters), precision),
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(obs.RelativeHumidityPercent), precision),
ApparentTemperatureC: roundedPtr(copyFloat64Ptr(obs.ApparentTemperatureC), precision),
PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...),
}
return &rounded
}

View File

@@ -1,395 +0,0 @@
// payload_test.go validates presenter conversion and payload shaping behavior.
// Layer: adapters/inbound/httpapi/presenter unit and schema tests.
package presenter
import (
"encoding/json"
"math"
"testing"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func TestObservationPayloadUS(t *testing.T) {
obs := &model.WeatherObservation{
StationID: "KSTL",
Timestamp: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
ConditionCode: 2,
TemperatureC: float64Ptr(20),
DewpointC: float64Ptr(10),
WindSpeedKmh: float64Ptr(100),
WindGustKmh: float64Ptr(80),
BarometricPressurePa: float64Ptr(101325),
VisibilityMeters: float64Ptr(1609.344),
ApparentTemperatureC: float64Ptr(25),
RelativeHumidityPercent: float64Ptr(50),
}
payload := ObservationPayload(obs, UnitsUS, 2)
converted, ok := payload.(WeatherObservationUS)
if !ok {
t.Fatalf("expected WeatherObservationUS payload, got %T", payload)
}
assertApprox(t, converted.TemperatureF, 68.0, 0.0001)
assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001)
assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001)
assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001)
if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil {
t.Fatalf("expected converted Fahrenheit fields to be populated")
}
}
func TestForecastPayloadUS(t *testing.T) {
issuedAt := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(1 * time.Hour)
run := &model.WeatherForecastRun{
LocationID: "stl",
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Product: model.ForecastProductHourly,
ElevationMeters: float64Ptr(1000),
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(1 * time.Hour),
ConditionCode: 63,
TemperatureC: float64Ptr(0),
TemperatureCMin: float64Ptr(-5),
TemperatureCMax: float64Ptr(5),
WindSpeedKmh: float64Ptr(64.37376),
PrecipitationAmountMm: float64Ptr(25.4),
SnowfallDepthMM: float64Ptr(50.8),
}},
}
payload := ForecastPayload(run, UnitsUS, 2, nil)
converted, ok := payload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
}
assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001)
if len(converted.Periods) != 1 {
t.Fatalf("expected 1 period, got %d", len(converted.Periods))
}
period := converted.Periods[0]
assertApprox(t, period.TemperatureF, 32.0, 0.0001)
assertApprox(t, period.TemperatureFMin, 23.0, 0.0001)
assertApprox(t, period.TemperatureFMax, 41.0, 0.0001)
assertApprox(t, period.WindSpeedMph, 40.0, 0.0001)
assertApprox(t, period.PrecipitationAmountIn, 1.0, 0.0001)
assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
}
func TestForecastPayloadOmitsLegacyDescriptionFields(t *testing.T) {
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
Periods: []model.WeatherForecastPeriod{{
StartTime: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 3, 20, 13, 0, 0, 0, time.UTC),
ConditionCode: model.WMOUnknown,
TextDescription: "Cloudy",
}},
}
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsMetric, 0, nil))
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsUS, 0, nil))
}
func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt.Add(1 * time.Hour),
EndTime: issuedAt.Add(2 * time.Hour),
ConditionCode: model.WMOUnknown,
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, loc)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertOffsetSeconds(t, metric.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60)
if !metric.IssuedAt.UTC().Equal(issuedAt) {
t.Fatalf("expected metric issuedAt to preserve instant")
}
usPayload := ForecastPayload(run, UnitsUS, 0, loc)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertOffsetSeconds(t, us.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60)
// Source model remains untouched.
assertOffsetSeconds(t, run.IssuedAt, 0)
assertOffsetSeconds(t, *run.UpdatedAt, 0)
}
func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(time.Hour)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(time.Hour),
ConditionCode: model.WMOUnknown,
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
if metric == run {
t.Fatalf("expected metric payload to be copied")
}
if metric.UpdatedAt == run.UpdatedAt {
t.Fatalf("expected updatedAt pointer to be copied")
}
if !metric.IssuedAt.Equal(run.IssuedAt) {
t.Fatalf("expected issuedAt to remain unchanged without timezone flag")
}
assertOffsetSeconds(t, metric.IssuedAt, 0)
assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
}
func TestDiscussionPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
shortIssuedAt := issuedAt.Add(-15 * time.Minute)
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
KeyMessages: []string{"msg one"},
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
}
payload := DiscussionPayload(run, UnitsMetric, loc)
discussion, ok := payload.(*model.WeatherForecastDiscussion)
if !ok {
t.Fatalf("expected *model.WeatherForecastDiscussion payload, got %T", payload)
}
if discussion == run {
t.Fatalf("expected discussion payload to be copied")
}
if discussion.ShortTerm == run.ShortTerm {
t.Fatalf("expected shortTerm pointer to be copied")
}
assertOffsetSeconds(t, discussion.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *discussion.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, *discussion.ShortTerm.IssuedAt, -5*60*60)
if discussion.KeyMessages[0] != "msg one" {
t.Fatalf("expected key message preserved, got %#v", discussion.KeyMessages)
}
assertOffsetSeconds(t, run.IssuedAt, 0)
}
func TestMetricCopyAndNilHandling(t *testing.T) {
obs := &model.WeatherObservation{
TemperatureC: float64Ptr(20.6),
}
metric := ObservationPayload(obs, UnitsMetric, 0)
metricObs, ok := metric.(*model.WeatherObservation)
if !ok {
t.Fatalf("expected metric payload to remain model type, got %T", metric)
}
if metricObs == obs {
t.Fatalf("expected metric payload to be copied")
}
assertApprox(t, metricObs.TemperatureC, 21, 0.0001)
assertApprox(t, obs.TemperatureC, 20.6, 0.0001)
if metricObs.TemperatureC == obs.TemperatureC {
t.Fatalf("expected temperature pointer copy, got same pointer")
}
if ObservationPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil observation input to return nil payload")
}
if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
t.Fatalf("expected nil forecast input to return nil payload")
}
if AlertsPayload(nil, UnitsUS) != nil {
t.Fatalf("expected nil alerts input to return nil payload")
}
if DiscussionPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil discussion input to return nil payload")
}
if CurrentConditionsPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil current conditions input to return nil payload")
}
}
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
conditions := &app.CurrentConditions{
TemperatureC: float64Ptr(20),
ApparentTemperatureC: float64Ptr(18),
DewpointC: float64Ptr(10),
RelativeHumidityPercent: float64Ptr(55),
WindSpeedKmh: float64Ptr(100),
WindDirectionDegrees: float64Ptr(225),
ConditionCode: 0,
IsDay: boolPtr(true),
}
metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2)
metric, ok := metricPayload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload)
}
assertApprox(t, metric.TemperatureC, 20, 0.0001)
assertApprox(t, metric.WindSpeedKmh, 100, 0.0001)
if metric.TemperatureF != nil || metric.WindSpeedMph != nil {
t.Fatalf("expected US fields omitted for metric payload")
}
if metric.ConditionText != "Sunny" {
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
}
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
us, ok := usPayload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload)
}
assertApprox(t, us.TemperatureF, 68, 0.0001)
assertApprox(t, us.WindSpeedMph, 62.14, 0.0001)
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
t.Fatalf("expected metric fields omitted for US payload")
}
}
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
night := false
payload := CurrentConditionsPayload(&app.CurrentConditions{
ConditionCode: 0,
IsDay: &night,
}, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
}
if metric.ConditionText != "Clear" {
t.Fatalf("expected condition text Clear, got %q", metric.ConditionText)
}
}
func TestCurrentConditionsPayloadRoundsHalfAwayFromZero(t *testing.T) {
payload := CurrentConditionsPayload(&app.CurrentConditions{
TemperatureC: float64Ptr(-1.5),
}, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
}
assertApprox(t, metric.TemperatureC, -2, 0.0001)
}
func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
run := &model.WeatherForecastRun{
Latitude: float64Ptr(38.627123),
Longitude: float64Ptr(-90.199456),
ElevationMeters: float64Ptr(10.499),
Product: model.ForecastProductHourly,
IssuedAt: time.Now().UTC(),
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertApprox(t, metric.Latitude, 38.627123, 0.000001)
assertApprox(t, metric.Longitude, -90.199456, 0.000001)
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
usPayload := ForecastPayload(run, UnitsUS, 0, nil)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertApprox(t, us.Latitude, 38.627123, 0.000001)
assertApprox(t, us.Longitude, -90.199456, 0.000001)
}
func float64Ptr(v float64) *float64 {
return &v
}
func boolPtr(v bool) *bool {
return &v
}
func assertApprox(t *testing.T, got *float64, want, eps float64) {
t.Helper()
if got == nil {
t.Fatalf("expected value near %f, got nil", want)
}
if math.Abs(*got-want) > eps {
t.Fatalf("expected %f +/- %f, got %f", want, eps, *got)
}
}
func assertOffsetSeconds(t *testing.T, ts time.Time, want int) {
t.Helper()
_, got := ts.Zone()
if got != want {
t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339))
}
}
func assertForecastPayloadHasNoLegacyDescriptionFields(t *testing.T, payload any) {
t.Helper()
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal(payload) error = %v", err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("json.Unmarshal(payload) error = %v", err)
}
periodsRaw, ok := root["periods"].([]any)
if !ok || len(periodsRaw) == 0 {
t.Fatalf("expected non-empty periods in payload: %#v", root["periods"])
}
period, ok := periodsRaw[0].(map[string]any)
if !ok {
t.Fatalf("expected first period map, got %#v", periodsRaw[0])
}
for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} {
if _, exists := period[key]; exists {
t.Fatalf("unexpected legacy field %q in payload period: %#v", key, period)
}
}
if period["textDescription"] != "Cloudy" {
t.Fatalf("expected textDescription Cloudy, got %#v", period["textDescription"])
}
}

View File

@@ -1,11 +0,0 @@
// units.go defines response unit modes for HTTP presentation.
// Layer: adapters/inbound/httpapi/presenter unit selection.
package presenter
// Units controls response-unit output formatting.
type Units string
const (
UnitsMetric Units = "metric"
UnitsUS Units = "us"
)

View File

@@ -1,137 +0,0 @@
// query_bind.go binds endpoint query parameters into typed request config.
// Layer: adapters/inbound/httpapi request binding.
package httpapi
import (
"net/http"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedapi/bind"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
type queryRequest struct {
Units presenter.Units
}
type precisionQueryRequest struct {
Units presenter.Units
Precision int
Timezone *time.Location
}
type timezoneQueryRequest struct {
Units presenter.Units
Timezone *time.Location
}
func bindQuery(r *http.Request) (queryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
})
if err != nil {
return queryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
return queryRequest{Units: units}, nil
}
func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
return bindPrecisionQueryInternal(r, false)
}
func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
return bindPrecisionQueryInternal(r, true)
}
func bindTimezoneQuery(r *http.Request) (timezoneQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, "tz", "TZ")
if err != nil {
return timezoneQueryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
tz, err := parseTimezoneQuery(r)
if err != nil {
return timezoneQueryRequest{}, err
}
return timezoneQueryRequest{
Units: units,
Timezone: tz,
}, nil
}
func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
normalizeCommonQueryValue(r, "precision")
allowedExtra := []string{"precision"}
if allowTimezone {
allowedExtra = append(allowedExtra, "tz", "TZ")
}
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, allowedExtra...)
if err != nil {
return precisionQueryRequest{}, err
}
precision, err := bind.OptionalInt(r, "precision", 0)
if err != nil {
return precisionQueryRequest{}, err
}
if err := bind.MinInt(precision, 0, "precision"); err != nil {
return precisionQueryRequest{}, err
}
if err := bind.MaxInt(precision, 2, "precision"); err != nil {
return precisionQueryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
var tz *time.Location
if allowTimezone {
tz, err = parseTimezoneQuery(r)
if err != nil {
return precisionQueryRequest{}, err
}
}
return precisionQueryRequest{
Units: units,
Precision: precision,
Timezone: tz,
}, nil
}

View File

@@ -1,23 +0,0 @@
// query_normalize.go normalizes common query values before binding.
// Layer: adapters/inbound/httpapi request pre-processing.
package httpapi
import (
"net/http"
"strings"
)
func normalizeCommonQueryValue(r *http.Request, key string) {
q := r.URL.Query()
values, ok := q[key]
if !ok || len(values) == 0 {
return
}
normalized := strings.ToLower(strings.TrimSpace(values[0]))
if normalized == values[0] {
return
}
q.Set(key, normalized)
r.URL.RawQuery = q.Encode()
}

View File

@@ -1,140 +0,0 @@
// query_timezone.go parses and validates timezone query parameters.
// Layer: adapters/inbound/httpapi request binding helpers.
package httpapi
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
apierrors "gitea.maximumdirect.net/ejr/feedapi/errors"
)
const maxUTCOffsetSeconds = 14 * 60 * 60
var usTimezoneAbbreviations = map[string]int{
"CDT": -5 * 60 * 60,
"CST": -6 * 60 * 60,
"EDT": -4 * 60 * 60,
"EST": -5 * 60 * 60,
"MDT": -6 * 60 * 60,
"MST": -7 * 60 * 60,
"PDT": -7 * 60 * 60,
"PST": -8 * 60 * 60,
}
var timezoneAliases = map[string]string{
"chicago": "America/Chicago",
"stl": "America/Chicago",
}
func parseTimezoneQuery(r *http.Request) (*time.Location, error) {
lower := strings.TrimSpace(r.URL.Query().Get("tz"))
upper := strings.TrimSpace(r.URL.Query().Get("TZ"))
if lower != "" && upper != "" && !strings.EqualFold(lower, upper) {
return nil, apierrors.InvalidParameter("tz and TZ must match when both are provided")
}
raw := lower
if raw == "" {
raw = upper
}
if raw == "" {
return nil, nil
}
loc, err := parseTimezoneValue(raw)
if err != nil {
return nil, apierrors.InvalidParameter(err.Error())
}
return loc, nil
}
func parseTimezoneValue(raw string) (*time.Location, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if offsetSeconds, ok, err := parseUTCOffsetSeconds(raw); ok {
if err != nil {
return nil, err
}
return time.FixedZone(formatUTCOffsetName(offsetSeconds), offsetSeconds), nil
}
if offsetSeconds, ok := usTimezoneAbbreviations[strings.ToUpper(raw)]; ok {
return time.FixedZone(strings.ToUpper(raw), offsetSeconds), nil
}
if alias, ok := timezoneAliases[strings.ToLower(raw)]; ok {
raw = alias
}
loc, err := time.LoadLocation(raw)
if err != nil {
return nil, fmt.Errorf("tz must be a valid timezone")
}
return loc, nil
}
func parseUTCOffsetSeconds(raw string) (int, bool, error) {
if len(raw) < 2 {
return 0, false, nil
}
sign := raw[0]
if sign != '+' && sign != '-' {
return 0, false, nil
}
remainder := raw[1:]
parts := strings.Split(remainder, ":")
if len(parts) > 2 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
hours, err := strconv.Atoi(parts[0])
if err != nil {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
if hours < 0 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
minutes := 0
if len(parts) == 2 {
if len(parts[1]) != 2 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
minutes, err = strconv.Atoi(parts[1])
if err != nil {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
}
if minutes < 0 || minutes > 59 {
return 0, true, fmt.Errorf("tz offset minutes must be between 00 and 59")
}
offset := (hours * 60 * 60) + (minutes * 60)
if sign == '-' {
offset = -offset
}
if offset < -maxUTCOffsetSeconds || offset > maxUTCOffsetSeconds {
return 0, true, fmt.Errorf("tz offset must be between -14 and +14 hours")
}
return offset, true, nil
}
func formatUTCOffsetName(offsetSeconds int) string {
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
hours := offsetSeconds / 3600
minutes := (offsetSeconds % 3600) / 60
return fmt.Sprintf("UTC%s%02d:%02d", sign, hours, minutes)
}

View File

@@ -1,20 +0,0 @@
// service.go defines the inbound service contract consumed by HTTP handlers.
// Layer: adapters/inbound/httpapi boundary to application service.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Service describes weather resource queries needed by the HTTP adapter.
type Service interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error)
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
}

View File

@@ -1,70 +0,0 @@
// alerts_mapper.go maps alert rows into weather model payloads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
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),
}
}
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),
},
}
}
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
}

View File

@@ -1,53 +0,0 @@
// alerts_queries.go contains SQL text for alert-run reads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
const (
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`
)

View File

@@ -1,110 +0,0 @@
// alerts_read.go executes alert-run, alerts, and reference queries.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestAlertRun(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) 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
}

View File

@@ -1,59 +0,0 @@
// alerts_rows.go defines row DTOs for alert-run, alert, and reference reads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import (
"database/sql"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type alertRunParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
AsOf time.Time
Latitude sql.NullFloat64
Longitude sql.NullFloat64
}
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
}
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
}

View File

@@ -1,30 +0,0 @@
// conditions_mapper.go maps current-conditions DB rows into app models.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapCurrentConditionsRow(row currentConditionsRow) *app.CurrentConditions {
if row.SampleCount == 0 {
return nil
}
conditionCode := model.WMOUnknown
if row.ConditionCode.Valid {
conditionCode = model.WMOCode(row.ConditionCode.Int64)
}
return &app.CurrentConditions{
TemperatureC: float64Ptr(row.TemperatureC),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
DewpointC: float64Ptr(row.DewpointC),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
ConditionCode: conditionCode,
IsDay: boolPtr(row.IsDay),
}
}

View File

@@ -1,50 +0,0 @@
// conditions_queries.go contains SQL text for current-conditions reads.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
const (
queryCurrentConditions = `
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
COUNT(*) AS sample_count,
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`
)

View File

@@ -1,39 +0,0 @@
// conditions_read.go executes current-conditions queries.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
)
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row currentConditionsRow
err := r.db.QueryRowContext(ctx, queryCurrentConditions, observationWindowMinutes).Scan(
&row.SampleCount,
&row.TemperatureC,
&row.ApparentTemperatureC,
&row.DewpointC,
&row.RelativeHumidityPercent,
&row.WindSpeedKmh,
&row.WindDirectionDegrees,
&row.ConditionCode,
&row.IsDay,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query current conditions: %w", err)
}
return mapCurrentConditionsRow(row), nil
}

View File

@@ -1,17 +0,0 @@
// conditions_rows.go defines row DTOs for current-conditions reads.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import "database/sql"
type currentConditionsRow struct {
SampleCount int64
TemperatureC sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
DewpointC sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
ConditionCode sql.NullInt64
IsDay sql.NullBool
}

View File

@@ -1,45 +0,0 @@
// discussion_mapper.go maps forecast discussion rows into weather model payloads.
// Layer: adapters/outbound/postgres discussion feature.
package postgres
import (
"database/sql"
"strings"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapDiscussionParentRow(row discussionParentRow) model.WeatherForecastDiscussion {
return model.WeatherForecastDiscussion{
OfficeID: stringValue(row.OfficeID),
OfficeName: stringValue(row.OfficeName),
Product: model.ForecastDiscussionProduct(strings.TrimSpace(row.Product)),
IssuedAt: row.IssuedAt.UTC(),
UpdatedAt: timePtr(row.UpdatedAt),
ShortTerm: discussionSectionPtr(row.ShortTermQualifier, row.ShortTermIssuedAt, row.ShortTermText),
LongTerm: discussionSectionPtr(row.LongTermQualifier, row.LongTermIssuedAt, row.LongTermText),
KeyMessages: nil,
}
}
func mapDiscussionKeyMessageRow(row discussionKeyMessageRow) string {
return stringValue(row.MessageText)
}
func discussionSectionPtr(
qualifier sql.NullString,
issuedAt sql.NullTime,
text sql.NullString,
) *model.WeatherForecastDiscussionSection {
q := stringValue(qualifier)
t := stringValue(text)
i := timePtr(issuedAt)
if q == "" && t == "" && i == nil {
return nil
}
return &model.WeatherForecastDiscussionSection{
Qualifier: q,
IssuedAt: i,
Text: t,
}
}

View File

@@ -1,31 +0,0 @@
// discussion_queries.go contains SQL text for forecast discussion reads.
// Layer: adapters/outbound/postgres discussion feature.
package postgres
const (
queryLatestForecastDiscussion = `
SELECT
event_id,
office_id,
office_name,
issued_at,
updated_at,
product,
short_term_qualifier,
short_term_issued_at,
short_term_text,
long_term_qualifier,
long_term_issued_at,
long_term_text
FROM forecast_discussions
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryForecastDiscussionKeyMessages = `
SELECT
message_index,
message_text
FROM forecast_discussion_key_messages
WHERE run_event_id = $1
ORDER BY message_index ASC`
)

View File

@@ -1,74 +0,0 @@
// discussion_read.go executes forecast discussion queries.
// Layer: adapters/outbound/postgres discussion feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row discussionParentRow
err := r.db.QueryRowContext(ctx, queryLatestForecastDiscussion).Scan(
&row.EventID,
&row.OfficeID,
&row.OfficeName,
&row.IssuedAt,
&row.UpdatedAt,
&row.Product,
&row.ShortTermQualifier,
&row.ShortTermIssuedAt,
&row.ShortTermText,
&row.LongTermQualifier,
&row.LongTermIssuedAt,
&row.LongTermText,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest forecast discussion: %w", err)
}
run := mapDiscussionParentRow(row)
keyMessages, err := r.loadForecastDiscussionKeyMessages(ctx, row.EventID)
if err != nil {
return nil, err
}
run.KeyMessages = keyMessages
return &run, nil
}
func (r *Repository) loadForecastDiscussionKeyMessages(ctx context.Context, eventID string) ([]string, error) {
rows, err := r.db.QueryContext(ctx, queryForecastDiscussionKeyMessages, eventID)
if err != nil {
return nil, fmt.Errorf("query forecast discussion key messages: %w", err)
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var row discussionKeyMessageRow
if err := rows.Scan(
&row.MessageIndex,
&row.MessageText,
); err != nil {
return nil, fmt.Errorf("scan forecast discussion key message row: %w", err)
}
out = append(out, mapDiscussionKeyMessageRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate forecast discussion key message rows: %w", err)
}
return out, nil
}

View File

@@ -1,28 +0,0 @@
// discussion_rows.go defines row DTOs for forecast discussion reads.
// Layer: adapters/outbound/postgres discussion feature.
package postgres
import (
"database/sql"
"time"
)
type discussionParentRow struct {
EventID string
OfficeID sql.NullString
OfficeName sql.NullString
IssuedAt time.Time
UpdatedAt sql.NullTime
Product string
ShortTermQualifier sql.NullString
ShortTermIssuedAt sql.NullTime
ShortTermText sql.NullString
LongTermQualifier sql.NullString
LongTermIssuedAt sql.NullTime
LongTermText sql.NullString
}
type discussionKeyMessageRow struct {
MessageIndex int
MessageText sql.NullString
}

View File

@@ -1,45 +0,0 @@
// forecast_mapper.go maps forecast rows into weather model payloads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
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),
}
}
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),
TextDescription: stringValue(row.TextDescription),
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),
}
}

View File

@@ -1,66 +0,0 @@
// forecast_queries.go contains SQL text for forecast reads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
const (
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`
queryLatestNarrativeForecast = `
SELECT
event_id,
location_id,
location_name,
issued_at,
updated_at,
product,
latitude,
longitude,
elevation_meters
FROM forecasts
WHERE product = 'narrative'
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryForecastPeriods = `
SELECT
period_index,
start_time,
end_time,
name,
is_day,
condition_code,
text_description,
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`
)

View File

@@ -1,104 +0,0 @@
// forecast_read.go executes forecast and period queries.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return r.loadLatestForecastRun(ctx, queryLatestHourlyForecast, "hourly")
}
func (r *Repository) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return r.loadLatestForecastRun(ctx, queryLatestNarrativeForecast, "narrative")
}
func (r *Repository) loadLatestForecastRun(
ctx context.Context,
parentQuery string,
productLabel string,
) (*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, parentQuery).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 %s forecast: %w", productLabel, 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) 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.TextDescription,
&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
}

View File

@@ -1,46 +0,0 @@
// forecast_rows.go defines row DTOs for forecast reads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import (
"database/sql"
"time"
)
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
}
type forecastPeriodRow struct {
PeriodIndex int
StartTime time.Time
EndTime time.Time
Name sql.NullString
IsDay sql.NullBool
ConditionCode int
TextDescription 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
}

View File

@@ -1,42 +0,0 @@
// observations_mapper.go maps observation rows into weather model payloads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"encoding/json"
"strings"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
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),
}
}
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
}

View File

@@ -1,33 +0,0 @@
// observations_queries.go contains SQL text for observation reads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
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`
)

View File

@@ -1,79 +0,0 @@
// observations_read.go executes observation and present-weather queries.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
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) 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
}

View File

@@ -1,32 +0,0 @@
// observations_rows.go defines row DTOs for observation reads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"database/sql"
"time"
)
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
}
type observationPresentWeatherRow struct {
WeatherIndex int
RawText sql.NullString
}

View File

@@ -1,20 +0,0 @@
// repository.go defines the Postgres repository shell and constructor.
// Layer: adapters/outbound/postgres repository root.
package postgres
import (
"database/sql"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
)
// Repository is a Postgres-backed implementation of weatherapi read ports.
type Repository struct {
db *sql.DB
}
var _ app.Repository = (*Repository)(nil)
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}

View File

@@ -1,226 +0,0 @@
// repository_test.go validates Postgres row mapping and attachment helpers.
// Layer: adapters/outbound/postgres mapper regression tests.
package postgres
import (
"database/sql"
"testing"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
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 TestMapDiscussionParentRowNullables(t *testing.T) {
issuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.FixedZone("CDT", -5*3600))
updatedAt := issuedAt.Add(time.Hour)
shortIssuedAt := issuedAt.Add(-5 * time.Minute)
discussion := mapDiscussionParentRow(discussionParentRow{
OfficeID: sql.NullString{String: "LSX", Valid: true},
OfficeName: sql.NullString{String: "National Weather Service Saint Louis MO", Valid: true},
IssuedAt: issuedAt,
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
Product: "afd",
ShortTermQualifier: sql.NullString{String: "(Tonight)", Valid: true},
ShortTermIssuedAt: sql.NullTime{Time: shortIssuedAt, Valid: true},
ShortTermText: sql.NullString{String: "Short term text", Valid: true},
})
if discussion.OfficeID != "LSX" {
t.Fatalf("expected office id LSX, got %q", discussion.OfficeID)
}
if discussion.Product != model.ForecastDiscussionProductAFD {
t.Fatalf("expected product afd, got %q", discussion.Product)
}
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected updatedAt to be UTC-normalized, got %v", discussion.UpdatedAt)
}
if discussion.ShortTerm == nil {
t.Fatalf("expected shortTerm section to be populated")
}
if discussion.ShortTerm.Qualifier != "(Tonight)" || discussion.ShortTerm.Text != "Short term text" {
t.Fatalf("unexpected shortTerm section: %+v", discussion.ShortTerm)
}
if discussion.LongTerm != nil {
t.Fatalf("expected longTerm nil, got %+v", discussion.LongTerm)
}
}
func TestMapDiscussionSectionNilWhenAllFieldsMissing(t *testing.T) {
got := discussionSectionPtr(sql.NullString{}, sql.NullTime{}, sql.NullString{})
if got != nil {
t.Fatalf("expected nil discussion section, got %+v", got)
}
}
func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) {
rows := []discussionKeyMessageRow{
{MessageIndex: 0, MessageText: sql.NullString{String: "first", Valid: true}},
{MessageIndex: 1, MessageText: sql.NullString{String: "second", Valid: true}},
}
got := make([]string, 0, len(rows))
for _, row := range rows {
got = append(got, mapDiscussionKeyMessageRow(row))
}
if len(got) != 2 || got[0] != "first" || got[1] != "second" {
t.Fatalf("unexpected key message order: %#v", got)
}
}
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)
}
}
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 0,
})
if got != nil {
t.Fatalf("expected nil for empty sample window, got %+v", got)
}
}
func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
isDay := true
got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 12,
TemperatureC: sql.NullFloat64{Float64: 15.5, Valid: true},
ApparentTemperatureC: sql.NullFloat64{Float64: 14.2, Valid: true},
DewpointC: sql.NullFloat64{Float64: 10.1, Valid: true},
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
IsDay: sql.NullBool{Bool: isDay, Valid: true},
})
if got == nil {
t.Fatalf("expected mapped current conditions")
}
if got.TemperatureC == nil || *got.TemperatureC != 15.5 {
t.Fatalf("expected temperature pointer 15.5, got %v", got.TemperatureC)
}
if got.ApparentTemperatureC == nil || *got.ApparentTemperatureC != 14.2 {
t.Fatalf("expected apparent temp pointer 14.2, got %v", got.ApparentTemperatureC)
}
if got.DewpointC == nil || *got.DewpointC != 10.1 {
t.Fatalf("expected dewpoint pointer 10.1, got %v", got.DewpointC)
}
if got.RelativeHumidityPercent == nil || *got.RelativeHumidityPercent != 72 {
t.Fatalf("expected rh pointer 72, got %v", got.RelativeHumidityPercent)
}
if got.WindSpeedKmh == nil || *got.WindSpeedKmh != 24.8 {
t.Fatalf("expected wind speed pointer 24.8, got %v", got.WindSpeedKmh)
}
if got.WindDirectionDegrees == nil || *got.WindDirectionDegrees != 182.5 {
t.Fatalf("expected wind direction pointer 182.5, got %v", got.WindDirectionDegrees)
}
if got.ConditionCode != 65 {
t.Fatalf("expected condition code 65, got %d", got.ConditionCode)
}
if got.IsDay == nil || !*got.IsDay {
t.Fatalf("expected isDay pointer true, got %v", got.IsDay)
}
}

View File

@@ -1,39 +0,0 @@
// scan_helpers.go provides shared sql.Null* conversion helpers.
// Layer: adapters/outbound/postgres helper utilities.
package postgres
import (
"database/sql"
"time"
)
func stringValue(v sql.NullString) string {
if !v.Valid {
return ""
}
return v.String
}
func boolPtr(v sql.NullBool) *bool {
if !v.Valid {
return nil
}
b := v.Bool
return &b
}
func float64Ptr(v sql.NullFloat64) *float64 {
if !v.Valid {
return nil
}
f := v.Float64
return &f
}
func timePtr(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time.UTC()
return &t
}

View File

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

@@ -1,7 +0,0 @@
// constants.go defines shared application-level constants.
// Layer: internal/app service behavior defaults.
package app
const (
ObservationWindowMinutesDefault = 30
)

View File

@@ -1,17 +0,0 @@
// current_conditions.go defines the current-conditions aggregate model.
// Layer: internal/app domain-adjacent read model.
package app
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
// CurrentConditions is an averaged current-conditions aggregate over a recent window.
type CurrentConditions struct {
TemperatureC *float64
ApparentTemperatureC *float64
DewpointC *float64
RelativeHumidityPercent *float64
WindSpeedKmh *float64
WindDirectionDegrees *float64
ConditionCode model.WMOCode
IsDay *bool
}

View File

@@ -1,52 +0,0 @@
// service.go defines application read ports and use-case orchestration.
// Layer: internal/app core business-facing API.
package app
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)
LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error)
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, 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) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return s.repo.LatestNarrativeForecast(ctx)
}
func (s *Service) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) {
return s.repo.LatestForecastDiscussion(ctx)
}
func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
return s.repo.LatestAlertRun(ctx)
}
func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) {
return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault)
}

View File

@@ -1,136 +0,0 @@
// service_test.go validates application service delegation behavior.
// Layer: internal/app tests for read use-case orchestration.
package app
import (
"context"
"errors"
"testing"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type fakeRepository struct {
observation *model.WeatherObservation
forecast *model.WeatherForecastRun
narrative *model.WeatherForecastRun
discussion *model.WeatherForecastDiscussion
alerts *model.WeatherAlertRun
conditions *CurrentConditions
err error
currentConditionsWindow int
}
func (r *fakeRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
return r.observation, r.err
}
func (r *fakeRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
return r.forecast, r.err
}
func (r *fakeRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
return r.narrative, r.err
}
func (r *fakeRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
return r.discussion, r.err
}
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
return r.alerts, r.err
}
func (r *fakeRepository) CurrentConditions(_ context.Context, observationWindowMinutes int) (*CurrentConditions, error) {
r.currentConditionsWindow = observationWindowMinutes
return r.conditions, r.err
}
func TestServiceDelegatesObservation(t *testing.T) {
repo := &fakeRepository{observation: &model.WeatherObservation{StationID: "KSTL"}}
svc := NewService(repo)
obs, err := svc.LatestObservation(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if obs == nil || obs.StationID != "KSTL" {
t.Fatalf("unexpected observation: %+v", obs)
}
}
func TestServiceDelegatesForecast(t *testing.T) {
repo := &fakeRepository{forecast: &model.WeatherForecastRun{LocationID: "stl"}}
svc := NewService(repo)
run, err := svc.LatestHourlyForecast(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected forecast: %+v", run)
}
}
func TestServiceDelegatesNarrativeForecast(t *testing.T) {
repo := &fakeRepository{narrative: &model.WeatherForecastRun{LocationID: "stl-narrative"}}
svc := NewService(repo)
run, err := svc.LatestNarrativeForecast(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl-narrative" {
t.Fatalf("unexpected forecast: %+v", run)
}
}
func TestServiceDelegatesAlerts(t *testing.T) {
repo := &fakeRepository{alerts: &model.WeatherAlertRun{LocationID: "stl"}}
svc := NewService(repo)
run, err := svc.LatestAlertRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected alert run: %+v", run)
}
}
func TestServiceDelegatesForecastDiscussion(t *testing.T) {
repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}}
svc := NewService(repo)
run, err := svc.LatestForecastDiscussion(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.OfficeID != "LSX" {
t.Fatalf("unexpected forecast discussion: %+v", run)
}
}
func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) {
repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}}
svc := NewService(repo)
_, err := svc.CurrentConditions(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.currentConditionsWindow != ObservationWindowMinutesDefault {
t.Fatalf("expected observation window %d, got %d", ObservationWindowMinutesDefault, repo.currentConditionsWindow)
}
}
func TestServicePropagatesErrors(t *testing.T) {
want := errors.New("boom")
repo := &fakeRepository{err: want}
svc := NewService(repo)
if _, err := svc.LatestObservation(context.Background()); !errors.Is(err, want) {
t.Fatalf("expected error %v, got %v", want, err)
}
}

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

@@ -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,41 +0,0 @@
{{- if .Data -}}
Current Conditions
{{- if .Data.ConditionText}}
Condition: {{.Data.ConditionText}}
{{- end}}
{{- if .Data.IsDayText}}
Is Day: {{.Data.IsDayText}}
{{- end}}
{{- if .Data.TemperatureC}}
Temperature (C): {{.Data.TemperatureC}}
{{- end}}
{{- if .Data.ApparentTemperatureC}}
Apparent Temperature (C): {{.Data.ApparentTemperatureC}}
{{- end}}
{{- if .Data.DewpointC}}
Dewpoint (C): {{.Data.DewpointC}}
{{- end}}
{{- if .Data.WindSpeedKmh}}
Wind Speed (km/h): {{.Data.WindSpeedKmh}}
{{- end}}
{{- if .Data.TemperatureF}}
Temperature (F): {{.Data.TemperatureF}}
{{- end}}
{{- if .Data.ApparentTemperatureF}}
Apparent Temperature (F): {{.Data.ApparentTemperatureF}}
{{- end}}
{{- if .Data.DewpointF}}
Dewpoint (F): {{.Data.DewpointF}}
{{- end}}
{{- if .Data.WindSpeedMph}}
Wind Speed (mph): {{.Data.WindSpeedMph}}
{{- end}}
{{- if .Data.RelativeHumidityPercent}}
Relative Humidity (%): {{.Data.RelativeHumidityPercent}}
{{- end}}
{{- if .Data.WindDirectionDegrees}}
Wind Direction (deg): {{.Data.WindDirectionDegrees}}
{{- end}}
{{- else -}}
No current conditions data available.
{{- end}}

View File

@@ -1,43 +0,0 @@
{{- if .Data -}}
Forecast Discussion
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
Product: {{.Data.Product}}
Issued At: {{.Data.IssuedAt}}
{{- if .Data.UpdatedAt}}
Updated At: {{.Data.UpdatedAt}}
{{- end}}
Key Messages: {{len .Data.KeyMessages}}
{{- range $i, $message := .Data.KeyMessages}}
[{{$i}}] {{$message}}
{{- end}}
{{- if .Data.ShortTerm}}
Short Term
{{- if .Data.ShortTerm.Qualifier}}
Qualifier: {{.Data.ShortTerm.Qualifier}}
{{- end}}
{{- if .Data.ShortTerm.IssuedAt}}
Issued At: {{.Data.ShortTerm.IssuedAt}}
{{- end}}
{{- if .Data.ShortTerm.Text}}
Text: {{.Data.ShortTerm.Text}}
{{- end}}
{{- end}}
{{- if .Data.LongTerm}}
Long Term
{{- if .Data.LongTerm.Qualifier}}
Qualifier: {{.Data.LongTerm.Qualifier}}
{{- end}}
{{- if .Data.LongTerm.IssuedAt}}
Issued At: {{.Data.LongTerm.IssuedAt}}
{{- end}}
{{- if .Data.LongTerm.Text}}
Text: {{.Data.LongTerm.Text}}
{{- end}}
{{- end}}
{{- else -}}
No forecast discussion 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,20 +0,0 @@
{{- if .Data -}}
Narrative 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}}
{{- if $period.Name}}
Name: {{$period.Name}}
{{- end}}
Condition Code: {{$period.ConditionCode}}
{{- if $period.TextDescription}}
Summary: {{$period.TextDescription}}
{{- end}}
{{- end}}
{{- else -}}
No narrative 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.