From 27817f9e43cd6db8ed3c9c899796851bbc218f8f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 17 Mar 2026 08:35:16 -0500 Subject: [PATCH] Initial MVP commit --- .woodpecker/build-image.yml | 17 ++ Dockerfile | 78 ++++++ README.md | 36 ++- cmd/weatherapi/main.go | 102 ++++++++ config.example.yml | 6 + go.mod | 15 ++ go.sum | 35 +++ internal/adapters/httpapi/server.go | 110 ++++++++ internal/adapters/httpapi/server_test.go | 234 ++++++++++++++++++ internal/adapters/postgres/alerts_repo.go | 65 +++++ internal/adapters/postgres/forecast_repo.go | 119 +++++++++ .../adapters/postgres/observations_repo.go | 106 ++++++++ internal/adapters/postgres/queries.go | 81 ++++++ internal/adapters/postgres/queries_test.go | 38 +++ internal/adapters/postgres/scan.go | 38 +++ internal/application/alerts/service.go | 57 +++++ internal/application/forecasts/service.go | 104 ++++++++ internal/application/observations/service.go | 84 +++++++ internal/application/units/converter.go | 35 +++ internal/application/units/converter_test.go | 43 ++++ internal/core/ports/ports.go | 74 ++++++ internal/platform/config/config.go | 120 +++++++++ internal/platform/config/config_test.go | 53 ++++ internal/platform/constants/constants.go | 30 +++ internal/platform/constants/conversion.go | 17 ++ .../platform/datasource/postgres/factory.go | 71 ++++++ internal/platform/datasource/registry.go | 64 +++++ internal/platform/timeparse/parse.go | 30 +++ internal/platform/timeparse/parse_test.go | 29 +++ 29 files changed, 1890 insertions(+), 1 deletion(-) create mode 100644 .woodpecker/build-image.yml create mode 100644 Dockerfile create mode 100644 cmd/weatherapi/main.go create mode 100644 config.example.yml create mode 100644 go.sum create mode 100644 internal/adapters/httpapi/server.go create mode 100644 internal/adapters/httpapi/server_test.go create mode 100644 internal/adapters/postgres/alerts_repo.go create mode 100644 internal/adapters/postgres/forecast_repo.go create mode 100644 internal/adapters/postgres/observations_repo.go create mode 100644 internal/adapters/postgres/queries.go create mode 100644 internal/adapters/postgres/queries_test.go create mode 100644 internal/adapters/postgres/scan.go create mode 100644 internal/application/alerts/service.go create mode 100644 internal/application/forecasts/service.go create mode 100644 internal/application/observations/service.go create mode 100644 internal/application/units/converter.go create mode 100644 internal/application/units/converter_test.go create mode 100644 internal/core/ports/ports.go create mode 100644 internal/platform/config/config.go create mode 100644 internal/platform/config/config_test.go create mode 100644 internal/platform/constants/constants.go create mode 100644 internal/platform/constants/conversion.go create mode 100644 internal/platform/datasource/postgres/factory.go create mode 100644 internal/platform/datasource/registry.go create mode 100644 internal/platform/timeparse/parse.go create mode 100644 internal/platform/timeparse/parse_test.go diff --git a/.woodpecker/build-image.yml b/.woodpecker/build-image.yml new file mode 100644 index 0000000..859f53a --- /dev/null +++ b/.woodpecker/build-image.yml @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ddc0ee --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 284262f..66060ba 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,37 @@ # weatherapi -A small HTTP API that serves a variety of weather-related endpoints. \ No newline at end of file +`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=` +- `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 +``` diff --git a/cmd/weatherapi/main.go b/cmd/weatherapi/main.go new file mode 100644 index 0000000..26d0fd5 --- /dev/null +++ b/cmd/weatherapi/main.go @@ -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) + } +} diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..f64b782 --- /dev/null +++ b/config.example.yml @@ -0,0 +1,6 @@ +- name: weatherdb + driver: postgres + params: + uri: postgres://weatherdb:5432/weatherdb?sslmode=disable + username: weatherdb + password: weatherdb diff --git a/go.mod b/go.mod index 5433da3..ce44b8d 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6fe46bd --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/adapters/httpapi/server.go b/internal/adapters/httpapi/server.go new file mode 100644 index 0000000..de253ff --- /dev/null +++ b/internal/adapters/httpapi/server.go @@ -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) +} diff --git a/internal/adapters/httpapi/server_test.go b/internal/adapters/httpapi/server_test.go new file mode 100644 index 0000000..1d6300e --- /dev/null +++ b/internal/adapters/httpapi/server_test.go @@ -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") + } +} diff --git a/internal/adapters/postgres/alerts_repo.go b/internal/adapters/postgres/alerts_repo.go new file mode 100644 index 0000000..e125c40 --- /dev/null +++ b/internal/adapters/postgres/alerts_repo.go @@ -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 +} diff --git a/internal/adapters/postgres/forecast_repo.go b/internal/adapters/postgres/forecast_repo.go new file mode 100644 index 0000000..a780d99 --- /dev/null +++ b/internal/adapters/postgres/forecast_repo.go @@ -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 +} diff --git a/internal/adapters/postgres/observations_repo.go b/internal/adapters/postgres/observations_repo.go new file mode 100644 index 0000000..3facb6d --- /dev/null +++ b/internal/adapters/postgres/observations_repo.go @@ -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) +} diff --git a/internal/adapters/postgres/queries.go b/internal/adapters/postgres/queries.go new file mode 100644 index 0000000..6bb31b6 --- /dev/null +++ b/internal/adapters/postgres/queries.go @@ -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; +` +) diff --git a/internal/adapters/postgres/queries_test.go b/internal/adapters/postgres/queries_test.go new file mode 100644 index 0000000..4e1bc02 --- /dev/null +++ b/internal/adapters/postgres/queries_test.go @@ -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) + } +} diff --git a/internal/adapters/postgres/scan.go b/internal/adapters/postgres/scan.go new file mode 100644 index 0000000..70a775d --- /dev/null +++ b/internal/adapters/postgres/scan.go @@ -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 +} diff --git a/internal/application/alerts/service.go b/internal/application/alerts/service.go new file mode 100644 index 0000000..e9a9fea --- /dev/null +++ b/internal/application/alerts/service.go @@ -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 +} diff --git a/internal/application/forecasts/service.go b/internal/application/forecasts/service.go new file mode 100644 index 0000000..a9c82ba --- /dev/null +++ b/internal/application/forecasts/service.go @@ -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 +} diff --git a/internal/application/observations/service.go b/internal/application/observations/service.go new file mode 100644 index 0000000..85a04b0 --- /dev/null +++ b/internal/application/observations/service.go @@ -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 +} diff --git a/internal/application/units/converter.go b/internal/application/units/converter.go new file mode 100644 index 0000000..a96a38e --- /dev/null +++ b/internal/application/units/converter.go @@ -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 +} diff --git a/internal/application/units/converter_test.go b/internal/application/units/converter_test.go new file mode 100644 index 0000000..48006ce --- /dev/null +++ b/internal/application/units/converter_test.go @@ -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) + } +} diff --git a/internal/core/ports/ports.go b/internal/core/ports/ports.go new file mode 100644 index 0000000..0b5da5f --- /dev/null +++ b/internal/core/ports/ports.go @@ -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) +} diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go new file mode 100644 index 0000000..17c0e41 --- /dev/null +++ b/internal/platform/config/config.go @@ -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 +} diff --git a/internal/platform/config/config_test.go b/internal/platform/config/config_test.go new file mode 100644 index 0000000..c94c865 --- /dev/null +++ b/internal/platform/config/config_test.go @@ -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") + } +} diff --git a/internal/platform/constants/constants.go b/internal/platform/constants/constants.go new file mode 100644 index 0000000..b917585 --- /dev/null +++ b/internal/platform/constants/constants.go @@ -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 +) diff --git a/internal/platform/constants/conversion.go b/internal/platform/constants/conversion.go new file mode 100644 index 0000000..742f8e0 --- /dev/null +++ b/internal/platform/constants/conversion.go @@ -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 +} diff --git a/internal/platform/datasource/postgres/factory.go b/internal/platform/datasource/postgres/factory.go new file mode 100644 index 0000000..b535ca0 --- /dev/null +++ b/internal/platform/datasource/postgres/factory.go @@ -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 +} diff --git a/internal/platform/datasource/registry.go b/internal/platform/datasource/registry.go new file mode 100644 index 0000000..0d81163 --- /dev/null +++ b/internal/platform/datasource/registry.go @@ -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)) +} diff --git a/internal/platform/timeparse/parse.go b/internal/platform/timeparse/parse.go new file mode 100644 index 0000000..27ae343 --- /dev/null +++ b/internal/platform/timeparse/parse.go @@ -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) +} diff --git a/internal/platform/timeparse/parse_test.go b/internal/platform/timeparse/parse_test.go new file mode 100644 index 0000000..4ed27f9 --- /dev/null +++ b/internal/platform/timeparse/parse_test.go @@ -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) + } + }) + } +}