Initial MVP commit
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-03-17 08:35:16 -05:00
parent 8c092c2247
commit 27817f9e43
29 changed files with 1890 additions and 1 deletions

View File

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

78
Dockerfile Normal file
View File

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

View File

@@ -1,3 +1,37 @@
# weatherapi
A small HTTP API that serves a variety of weather-related endpoints.
`weatherapi` is a small HTTP API that serves weather data backed by the
PostgreSQL schema populated by `weatherfeeder`.
## Config
`weatherapi` reads a YAML config file that is either:
- a top-level list of database entries, or
- an object with `databases:`.
Example:
```yaml
- name: weatherdb
driver: postgres
params:
uri: postgres://weatherdb:5432/weatherdb?sslmode=disable
username: weatherdb
password: weatherdb
```
## Endpoints
- `GET /observations/current`
- `GET /forecast?timestamp=<RFC3339 timestamp>`
- `GET /alerts/current`
By default, API output is US units for temperature and wind speed:
- temperature fields: Fahrenheit (`*F`)
- wind fields: mph (`*Mph`)
## Run
```bash
go run ./cmd/weatherapi -config ./config.yml -addr :8080
```

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

@@ -0,0 +1,102 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/httpapi"
adapterpg "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/postgres"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"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() {
cfgPath := flag.String("config", "config.yml", "path to YAML config")
addr := flag.String("addr", constants.DefaultHTTPAddr, "HTTP listen address")
dbName := flag.String("db-name", "", "optional configured database name to use")
flag.Parse()
cfg, err := config.Load(*cfgPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
dbCfg, err := chooseDatabase(cfg, *dbName)
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)
}
converter, err := newDefaultConverter()
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, converter, constants.ObservationWindow)
fcSvc := forecasts.NewService(fcRepo, converter, constants.ForecastQueryLimit)
alertsSvc := alerts.NewService(alertRepo)
server := httpapi.NewServer(obsSvc, fcSvc, alertsSvc)
httpServer := &http.Server{
Addr: *addr,
Handler: server.Handler(),
}
log.Printf("weatherapi listening on %s (db=%s driver=%s units=%s)", *addr, dbCfg.Name, dbCfg.Driver, constants.DefaultOutputUnitSystem)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}
func chooseDatabase(cfg *config.Config, name string) (config.DatabaseConfig, error) {
if cfg == nil {
return config.DatabaseConfig{}, fmt.Errorf("config is nil")
}
if name == "" {
return cfg.Databases[0], nil
}
db, ok := cfg.FindDatabase(name)
if !ok {
return config.DatabaseConfig{}, fmt.Errorf("database %q not found in config", name)
}
return db, nil
}
func newDefaultConverter() (units.Converter, error) {
switch constants.DefaultOutputUnitSystem {
case "us":
return units.USConverter{}, nil
default:
return nil, fmt.Errorf("unsupported default output unit system %q", constants.DefaultOutputUnitSystem)
}
}

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

15
go.mod
View File

@@ -1,3 +1,18 @@
module gitea.maximumdirect.net/ejr/weatherapi
go 1.25.5
require (
github.com/jackc/pgx/v5 v5.8.0
gopkg.in/yaml.v3 v3.0.1
)
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
)

35
go.sum Normal file
View File

@@ -0,0 +1,35 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,110 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/timeparse"
)
type ObservationService interface {
GetCurrent(ctx context.Context) (observations.CurrentResponse, error)
}
type ForecastService interface {
GetByTimestamp(ctx context.Context, ts time.Time) (forecasts.Response, error)
}
type AlertService interface {
GetCurrent(ctx context.Context) (alerts.Response, error)
}
type Server struct {
obsSvc ObservationService
fcSvc ForecastService
alertsSvc AlertService
}
func NewServer(obsSvc ObservationService, fcSvc ForecastService, alertsSvc AlertService) *Server {
return &Server{obsSvc: obsSvc, fcSvc: fcSvc, alertsSvc: alertsSvc}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/observations/current", s.handleObservationsCurrent)
mux.HandleFunc("/forecast", s.handleForecast)
mux.HandleFunc("/alerts/current", s.handleAlertsCurrent)
return mux
}
func (s *Server) handleObservationsCurrent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
resp, err := s.obsSvc.GetCurrent(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch current 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
}
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)
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)
}
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,234 @@
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/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.ObservationSummaryMetric
conditions []ports.ObservationConditionMetric
precip []string
summaryWin time.Duration
conditionsWin time.Duration
precipWin time.Duration
}
func (f *fakeObservationRepo) GetCurrentSummary(_ context.Context, window time.Duration) (ports.ObservationSummaryMetric, error) {
f.summaryWin = window
return f.summary, nil
}
func (f *fakeObservationRepo) ListCurrentConditions(_ context.Context, window time.Duration) ([]ports.ObservationConditionMetric, error) {
f.conditionsWin = window
return f.conditions, nil
}
func (f *fakeObservationRepo) ListCurrentPrecipitationEvents(_ context.Context, window time.Duration) ([]string, error) {
f.precipWin = window
return f.precip, nil
}
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 TestForecastInvalidTimestampReturns400(t *testing.T) {
fcRepo := &fakeForecastRepo{}
obsRepo := &fakeObservationRepo{}
server := NewServer(
observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow),
forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit),
alerts.NewService(fakeAlertRepo{}),
).Handler()
req := httptest.NewRequest(http.MethodGet, "/forecast?timestamp=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 TestObservationsCurrentReturnsUSUnits(t *testing.T) {
summaryTemp := 20.0
summaryAppTemp := 15.0
condTemp := 10.0
station := "KSTL"
text := "Cloudy"
providerText := "OVC"
conditionText := "Cloudy"
obsRepo := &fakeObservationRepo{
summary: ports.ObservationSummaryMetric{
TemperatureC: &summaryTemp,
ApparentTemperatureC: &summaryAppTemp,
},
conditions: []ports.ObservationConditionMetric{
{
StationID: &station,
ObservedAt: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
TemperatureC: &condTemp,
TextDescription: &text,
ProviderRawDescription: &providerText,
ConditionText: &conditionText,
},
},
precip: []string{"rain"},
}
fcRepo := &fakeForecastRepo{}
server := NewServer(
observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow),
forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit),
alerts.NewService(fakeAlertRepo{}),
).Handler()
req := httptest.NewRequest(http.MethodGet, "/observations/current", nil)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
summary := payload["summary"].(map[string]any)
if got := summary["temperatureF"].(float64); got != 68.0 {
t.Fatalf("expected summary temperatureF=68.0, got %v", got)
}
if got := summary["apparentTemperatureF"].(float64); got != 59.0 {
t.Fatalf("expected summary apparentTemperatureF=59.0, got %v", got)
}
if _, exists := summary["temperatureC"]; exists {
t.Fatalf("did not expect metric key temperatureC in US response")
}
conditions := payload["conditions"].([]any)
first := conditions[0].(map[string]any)
if got := first["temperatureF"].(float64); got != 50.0 {
t.Fatalf("expected conditions[0].temperatureF=50.0, got %v", got)
}
if obsRepo.summaryWin != constants.ObservationWindow || obsRepo.conditionsWin != constants.ObservationWindow || obsRepo.precipWin != constants.ObservationWindow {
t.Fatalf("expected observation window %s to be used", constants.ObservationWindow)
}
}
func TestForecastReturnsUSUnits(t *testing.T) {
tempC := 0.0
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,
},
},
}
obsRepo := &fakeObservationRepo{}
server := NewServer(
observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow),
forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit),
alerts.NewService(fakeAlertRepo{}),
).Handler()
req := httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z", nil)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
periods := payload["periods"].([]any)
first := periods[0].(map[string]any)
if got := first["temperatureF"].(float64); got != 32.0 {
t.Fatalf("expected temperatureF=32.0, got %v", got)
}
if got := first["temperatureFMin"].(float64); got != 30.2 {
t.Fatalf("expected temperatureFMin=30.2, got %v", got)
}
if got := first["temperatureFMax"].(float64); got != 33.8 {
t.Fatalf("expected temperatureFMax=33.8, got %v", got)
}
if got := first["apparentTemperatureF"].(float64); got != 28.4 {
t.Fatalf("expected apparentTemperatureF=28.4, got %v", got)
}
if got := first["windSpeedMph"].(float64); got != 6.2 {
t.Fatalf("expected windSpeedMph=6.2, got %v", got)
}
if got := first["windGustMph"].(float64); got != 10.0 {
t.Fatalf("expected windGustMph=10.0, got %v", got)
}
if _, exists := first["windSpeedKmh"]; exists {
t.Fatalf("did not expect metric key windSpeedKmh in US 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

@@ -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,106 @@
package postgres
import (
"context"
"database/sql"
"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) GetCurrentSummary(ctx context.Context, window time.Duration) (ports.ObservationSummaryMetric, error) {
var temperature sql.NullFloat64
var apparent sql.NullFloat64
if err := r.pool.QueryRow(ctx, queryObservationSummary, windowMinutes(window)).Scan(&temperature, &apparent); err != nil {
return ports.ObservationSummaryMetric{}, fmt.Errorf("query observation summary: %w", err)
}
return ports.ObservationSummaryMetric{
TemperatureC: ptrFloat64(temperature),
ApparentTemperatureC: ptrFloat64(apparent),
}, nil
}
func (r *ObservationRepository) ListCurrentConditions(ctx context.Context, window time.Duration) ([]ports.ObservationConditionMetric, error) {
rows, err := r.pool.Query(ctx, queryObservationConditions, windowMinutes(window))
if err != nil {
return nil, fmt.Errorf("query observation conditions: %w", err)
}
defer rows.Close()
out := make([]ports.ObservationConditionMetric, 0)
for rows.Next() {
var stationID sql.NullString
var observedAt time.Time
var temperature sql.NullFloat64
var textDescription sql.NullString
var providerRawDescription sql.NullString
var conditionText sql.NullString
if err := rows.Scan(
&stationID,
&observedAt,
&temperature,
&textDescription,
&providerRawDescription,
&conditionText,
); err != nil {
return nil, fmt.Errorf("scan observation condition row: %w", err)
}
out = append(out, ports.ObservationConditionMetric{
StationID: ptrString(stationID),
ObservedAt: observedAt,
TemperatureC: ptrFloat64(temperature),
TextDescription: ptrString(textDescription),
ProviderRawDescription: ptrString(providerRawDescription),
ConditionText: ptrString(conditionText),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation conditions rows: %w", err)
}
return out, nil
}
func (r *ObservationRepository) ListCurrentPrecipitationEvents(ctx context.Context, window time.Duration) ([]string, error) {
rows, err := r.pool.Query(ctx, queryObservationPrecipitation, windowMinutes(window))
if err != nil {
return nil, fmt.Errorf("query observation precipitation events: %w", err)
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var rawText sql.NullString
if err := rows.Scan(&rawText); err != nil {
return nil, fmt.Errorf("scan observation precipitation row: %w", err)
}
if rawText.Valid {
out = append(out, rawText.String)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation precipitation rows: %w", err)
}
return out, nil
}
func windowMinutes(window time.Duration) int {
return int(window / time.Minute)
}

View File

@@ -0,0 +1,81 @@
package postgres
const (
queryObservationSummary = `
SELECT
AVG(temperature_c) AS temperature_c,
AVG(apparent_temperature_c) AS apparent_temperature_c
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1);
`
queryObservationConditions = `
SELECT
station_id,
observed_at,
temperature_c,
text_description,
provider_raw_description,
condition_text
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
ORDER BY observed_at DESC;
`
queryObservationPrecipitation = `
SELECT
raw_text
FROM observation_present_weather
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
ORDER BY observed_at DESC;
`
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,38 @@
package postgres
import (
"strings"
"testing"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
func TestQueriesUseParametersNotHardcodedValues(t *testing.T) {
if !strings.Contains(queryObservationSummary, "make_interval(mins => $1)") {
t.Fatalf("observation summary query must use $1 minutes parameter")
}
if strings.Contains(queryObservationSummary, "30 minutes") {
t.Fatalf("observation summary query should not hardcode 30 minutes")
}
if !strings.Contains(queryObservationConditions, "make_interval(mins => $1)") {
t.Fatalf("observation conditions query must use $1 minutes parameter")
}
if !strings.Contains(queryObservationPrecipitation, "make_interval(mins => $1)") {
t.Fatalf("observation precipitation query must use $1 minutes parameter")
}
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,38 @@
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 ptrTime(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time
return &t
}

View File

@@ -0,0 +1,57 @@
package alerts
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
)
type Service struct {
repo ports.AlertRepository
}
func NewService(repo ports.AlertRepository) *Service {
return &Service{repo: repo}
}
type Response struct {
Alerts []AlertResponse `json:"alerts"`
}
type AlertResponse struct {
Effective *time.Time `json:"effective"`
Expires *time.Time `json:"expires"`
Severity *string `json:"severity"`
Event *string `json:"event"`
Headline *string `json:"headline"`
Instruction *string `json:"instruction"`
Description *string `json:"description"`
}
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,104 @@
package forecasts
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
)
type Service struct {
repo ports.ForecastRepository
converter units.Converter
limit int
}
func NewService(repo ports.ForecastRepository, converter units.Converter, limit int) *Service {
return &Service{repo: repo, converter: converter, 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"`
IsDay *bool `json:"isDay"`
ConditionCode int `json:"conditionCode"`
ConditionText *string `json:"conditionText"`
ProviderRawDescription *string `json:"providerRawDescription"`
TextDescription *string `json:"textDescription"`
DetailedText *string `json:"detailedText"`
IconURL *string `json:"iconUrl"`
TemperatureF *float64 `json:"temperatureF"`
TemperatureFMin *float64 `json:"temperatureFMin"`
TemperatureFMax *float64 `json:"temperatureFMax"`
DewpointF *float64 `json:"dewpointF"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees"`
WindSpeedMph *float64 `json:"windSpeedMph"`
WindGustMph *float64 `json:"windGustMph"`
BarometricPressurePa *float64 `json:"barometricPressurePa"`
VisibilityMeters *float64 `json:"visibilityMeters"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF"`
CloudCoverPercent *float64 `json:"cloudCoverPercent"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent"`
PrecipitationAmountMm *float64 `json:"precipitationAmountMm"`
SnowfallDepthMm *float64 `json:"snowfallDepthMm"`
UVIndex *float64 `json:"uvIndex"`
}
func (s *Service) GetByTimestamp(ctx context.Context, ts time.Time) (Response, error) {
if s == nil {
return Response{}, fmt.Errorf("forecasts service is nil")
}
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 {
periods = append(periods, 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,
TemperatureF: s.converter.TemperatureCToOutput(p.TemperatureC),
TemperatureFMin: s.converter.TemperatureCToOutput(p.TemperatureCMin),
TemperatureFMax: s.converter.TemperatureCToOutput(p.TemperatureCMax),
DewpointF: s.converter.TemperatureCToOutput(p.DewpointC),
RelativeHumidityPercent: p.RelativeHumidityPercent,
WindDirectionDegrees: p.WindDirectionDegrees,
WindSpeedMph: s.converter.SpeedKmhToOutput(p.WindSpeedKmh),
WindGustMph: s.converter.SpeedKmhToOutput(p.WindGustKmh),
BarometricPressurePa: p.BarometricPressurePa,
VisibilityMeters: p.VisibilityMeters,
ApparentTemperatureF: s.converter.TemperatureCToOutput(p.ApparentTemperatureC),
CloudCoverPercent: p.CloudCoverPercent,
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitationPercent,
PrecipitationAmountMm: p.PrecipitationAmountMm,
SnowfallDepthMm: p.SnowfallDepthMm,
UVIndex: p.UVIndex,
})
}
return Response{
Timestamp: ts,
Periods: periods,
}, nil
}

View File

@@ -0,0 +1,84 @@
package observations
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
)
type Service struct {
repo ports.ObservationRepository
converter units.Converter
window time.Duration
}
func NewService(repo ports.ObservationRepository, converter units.Converter, window time.Duration) *Service {
return &Service{repo: repo, converter: converter, window: window}
}
type CurrentResponse struct {
Summary SummaryResponse `json:"summary"`
Conditions []ConditionResponse `json:"conditions"`
PrecipitationEvents []string `json:"precipitationEvents"`
}
type SummaryResponse struct {
TemperatureF *float64 `json:"temperatureF"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF"`
WindowMinutes int `json:"windowMinutes"`
}
type ConditionResponse struct {
StationID *string `json:"stationId"`
ObservedAt time.Time `json:"observedAt"`
TemperatureF *float64 `json:"temperatureF"`
TextDescription *string `json:"textDescription"`
ProviderRawDescription *string `json:"providerRawDescription"`
ConditionText *string `json:"conditionText"`
}
func (s *Service) GetCurrent(ctx context.Context) (CurrentResponse, error) {
if s == nil {
return CurrentResponse{}, fmt.Errorf("observations service is nil")
}
summary, err := s.repo.GetCurrentSummary(ctx, s.window)
if err != nil {
return CurrentResponse{}, err
}
conditionsMetric, err := s.repo.ListCurrentConditions(ctx, s.window)
if err != nil {
return CurrentResponse{}, err
}
precip, err := s.repo.ListCurrentPrecipitationEvents(ctx, s.window)
if err != nil {
return CurrentResponse{}, err
}
conditions := make([]ConditionResponse, 0, len(conditionsMetric))
for _, c := range conditionsMetric {
conditions = append(conditions, ConditionResponse{
StationID: c.StationID,
ObservedAt: c.ObservedAt,
TemperatureF: s.converter.TemperatureCToOutput(c.TemperatureC),
TextDescription: c.TextDescription,
ProviderRawDescription: c.ProviderRawDescription,
ConditionText: c.ConditionText,
})
}
return CurrentResponse{
Summary: SummaryResponse{
TemperatureF: s.converter.TemperatureCToOutput(summary.TemperatureC),
ApparentTemperatureF: s.converter.TemperatureCToOutput(summary.ApparentTemperatureC),
WindowMinutes: int(s.window / time.Minute),
},
Conditions: conditions,
PrecipitationEvents: precip,
}, nil
}

View File

@@ -0,0 +1,35 @@
package units
import (
"math"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
type Converter interface {
TemperatureCToOutput(celsius *float64) *float64
SpeedKmhToOutput(kmh *float64) *float64
}
type USConverter struct{}
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
}
func round(v float64, precision int) float64 {
pow := math.Pow(10, float64(precision))
return math.Round(v*pow) / pow
}

View File

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

View File

@@ -0,0 +1,74 @@
package ports
import (
"context"
"time"
)
type ObservationSummaryMetric struct {
TemperatureC *float64
ApparentTemperatureC *float64
}
type ObservationConditionMetric struct {
StationID *string
ObservedAt time.Time
TemperatureC *float64
TextDescription *string
ProviderRawDescription *string
ConditionText *string
}
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 {
GetCurrentSummary(ctx context.Context, window time.Duration) (ObservationSummaryMetric, error)
ListCurrentConditions(ctx context.Context, window time.Duration) ([]ObservationConditionMetric, error)
ListCurrentPrecipitationEvents(ctx context.Context, window time.Duration) ([]string, error)
}
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,30 @@
package constants
import "time"
const (
// ObservationWindow defines how far back observations are queried.
ObservationWindow = 30 * time.Minute
// ForecastQueryLimit defines the max forecast periods returned.
ForecastQueryLimit = 5
// DefaultOutputUnitSystem is the API's default output unit system.
DefaultOutputUnitSystem = "us"
// 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)
}
})
}
}