54 Commits

Author SHA1 Message Date
f4dd701204 Cleanup outlook endpoints and completed roadmap documentation
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 09:40:49 -05:00
6316783c3a Record active alert verification
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 14:11:42 +00:00
cdfe8881d9 Document active alert filtering behavior 2026-06-12 14:10:16 +00:00
8d0bc90f9d Cover active alert text rendering 2026-06-12 14:08:19 +00:00
12a7447494 Wire active alert filtering into HTTP 2026-06-12 14:05:33 +00:00
dd2f24316c Filter active alerts in app service 2026-06-12 14:03:00 +00:00
90abc536dd Add implementation plan to clean up expired alerts
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 08:57:30 -05:00
370d7c0928 Update the weatherfeeder dependency to v0.12.0
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 13:34:33 +00:00
b556052966 Record outlook v2 verification
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-06-12 13:09:38 +00:00
c7760742e6 Document outlook v2 behavior 2026-06-12 13:07:17 +00:00
beeaeeaecb Cover outlook discussions in endpoint responses 2026-06-12 13:02:12 +00:00
1f2459923f Present outlook discussions in responses 2026-06-12 13:00:11 +00:00
fa1b2cb390 Filter outlook discussions with retained outlooks 2026-06-12 12:58:28 +00:00
82dd304f10 Read outlook discussions from Postgres 2026-06-12 12:56:20 +00:00
14c6954296 Update outlook dependency for v2 compile boundary 2026-06-12 12:52:05 +00:00
0135eb1153 Add roadmap and implementation plan to support the new outlook schema
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-06-12 07:47:01 -05:00
b4149f00c5 Refresh convective outlook roadmap docs
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-11 15:50:06 +00:00
83cb9abcb9 Finalize convective outlook rollout 2026-06-11 15:48:00 +00:00
993621e3b3 Document convective outlook endpoints 2026-06-11 15:46:00 +00:00
e897ae52df Add convective outlook presentation 2026-06-11 15:41:38 +00:00
d6734d5ffc Add convective outlook HTTP routes 2026-06-11 15:38:19 +00:00
63c8f33a2a Add convective outlook Postgres read adapter 2026-06-11 15:33:26 +00:00
6e0b71f6f4 Add convective outlook application filtering 2026-06-11 15:27:03 +00:00
27d6183b24 Update dependency and docs to weatherfeeder v0.11.0
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-11 10:22:10 -05:00
9b29cb388c Add a staged implementation plan and feature roadmap for SPC outlook support
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-11 09:46:46 -05:00
a31f97f807 Remove the completed documentation roadmap and add AGENTS.md pointing to the new policy documentation 2026-06-11 09:40:07 -05:00
3ec0a9bd84 Refresh documentation cross-links
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-11 14:25:42 +00:00
42321eb166 Add integration and development documentation 2026-06-11 14:22:57 +00:00
9fe480d3ab Add internal architecture documentation 2026-06-11 14:19:51 +00:00
ec027e34cc Add operations and troubleshooting documentation 2026-06-11 14:16:22 +00:00
a0f5e85516 Rewrite public API documentation 2026-06-11 14:13:46 +00:00
b4bdd2e4f1 Add orientation CLI and config documentation 2026-06-11 14:10:27 +00:00
a97de4c720 Added initial policy documentation and a docs implementation roadmap
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-11 08:21:47 -05:00
ecea856e8e Implemented weather stories support
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-05-30 07:06:29 -05:00
bde515d146 Add initial API documentation
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-05-29 20:46:06 -05:00
626df6bfc1 Made forecast-period conditionCode optional 2026-05-28 07:46:57 -05:00
6ea27cb2c5 Added new, focused forecast discussion endpoints for key messages, short term, and long term discussions
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-29 08:05:16 -05:00
0bccfc50d9 Implemented the weather forecast discussion product from weatherfeeder v0.8.3
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-28 17:36:35 -05:00
291a9178c8 Added new endpoints under /forecast/narrative
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-27 22:39:17 -05:00
78dc7817e9 Simplified the forecast schema and removed fields deprecated upstream in weatherfeeder
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-26 21:35:39 -05:00
dbefa8ed28 Added support for /forecast/hourly/today and /forecast/hourly/tomorrow endpoints
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-26 20:44:55 -05:00
b3ac19a65d Add timezone support to /forecast/hourly endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-26 20:06:17 -05:00
6806de3c0a Added support for rounding of values by default in API responses
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-20 11:30:11 -05:00
bb18bcfb15 Refactored the application structure to better separate concerns between files and packages
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-20 09:44:53 -05:00
8deb4fd12e Add a /conditions/current endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-19 23:16:26 -05:00
26a52f8c44 Add US unit support for weather observations and forecasts
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-19 22:36:15 -05:00
6e8adcc9cc Updated Dockerfile
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-03-19 21:38:29 -05:00
312e738b25 Patch the Dockerfile so that go build reuses the module cache
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 21:20:25 -05:00
d55b4be7ec Updated Dockerfile and woodpecker to use private Gitea login credentials
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 21:09:10 -05:00
56f96c4a7a Bugfix to accommodate private upstream repository for feedapi
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 20:58:01 -05:00
e7a9893824 Update go.mod
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 19:57:48 -05:00
a74591382a Add Dockerfile
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 19:55:41 -05:00
67beeb4d5e Add build image configuration for Woodpecker CI
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-03-19 19:53:54 -05:00
b01383e530 Initial MVP commit 2026-03-19 19:52:13 -05:00
125 changed files with 11659 additions and 2618 deletions

View File

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

4
AGENTS.md Normal file
View File

@@ -0,0 +1,4 @@
Please carefully review the documents in `docs/policy` before making any changes to this repository.
- `architecture.md` provides the canonical high-level architecture policy for this repository.
- `development.md` provides more granular development policy for this repository.
- `documentation.md` provides the canonical documentation policy for this repository.

View File

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

View File

@@ -1,42 +1,46 @@
# weatherapi # weatherapi
`weatherapi` is a small HTTP API that serves weather data backed by the `weatherapi` is a read-only HTTP API for weather data already stored by
PostgreSQL schema populated by `weatherfeeder`. `weatherfeeder` in PostgreSQL. It exposes the latest observations, current
conditions, alerts, forecasts, discussions, weather stories, and convective
outlooks as JSON, XML, or text.
## Config ## Quickstart
`weatherapi` reads a YAML config file that is either: The service needs a reachable Postgres database with weatherfeeder-owned tables
- a top-level list of database entries, or and the configured text template directory.
- an object with `databases:`.
Example: ```sh
go run ./cmd/weatherapi -config config.yml
```yaml
- name: weatherdb
driver: postgres
params:
uri: postgres://weatherdb:5432/weatherdb?sslmode=disable
username: weatherdb
password: weatherdb
``` ```
## Endpoints The checked-in `config.yml` is a local sample. For new deployments, start from
[`examples/config.minimal.yml`](examples/config.minimal.yml) or
[`examples/config.production.yml`](examples/config.production.yml) and provide
database credentials through your normal secret-management process.
- `GET /conditions/current` ## Endpoint Families
- `GET /observations?count=<1..100>&units=<us|metric>`
- `GET /forecast?timestamp=<RFC3339 timestamp>`
- `GET /alerts/current`
Default unit behavior: - Observations and current conditions
- `/conditions/current`: US units - Active alerts
- `/observations`: metric units - Hourly and narrative forecasts
- `/forecast`: US units - Forecast discussions
- Weather stories
- Convective outlooks
`/conditions/current`, `/observations`, and `/forecast` support `?units=us|metric`. Common query parameters include `format`, `units`, and route-specific options
`/observations` defaults to `count=5` and validates `count` strictly in range `1..100`. such as forecast `precision`, timezone `tz` / `TZ`, and outlook filters
`day` and `outlookType`.
## Run See [`docs/api.md`](docs/api.md) for the HTTP contract.
```bash ## Documentation
go run ./cmd/weatherapi -config ./config.yml -addr :8080
``` - [`docs/api.md`](docs/api.md): public HTTP API reference
- [`docs/cli.md`](docs/cli.md): command-line usage
- [`docs/config.md`](docs/config.md): YAML configuration reference
- [`docs/operations.md`](docs/operations.md): deployment and runtime operations
- [`docs/troubleshooting.md`](docs/troubleshooting.md): symptom-oriented fixes
- [`examples/requests.http`](examples/requests.http): copyable requests for implemented endpoints
- [`docs/policy/architecture.md`](docs/policy/architecture.md): development architecture and invariants
- [`docs/policy/development.md`](docs/policy/development.md): contributor workflow and update checklists

View File

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

View File

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

13
config.yml Normal file
View File

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

497
docs/api.md Normal file
View File

@@ -0,0 +1,497 @@
# weatherapi HTTP API
This is the canonical public HTTP contract for `weatherapi`. The service is a
read-only API over the latest weather records available in the configured
weatherfeeder-populated Postgres database.
## Base URL
All paths are relative to the deployment root:
```text
http://localhost:8080
```
Use your deployment host in production.
## Authentication
`weatherapi` does not implement authentication or authorization. Put access
control in front of the service when a deployment requires it.
## Response Envelope
Successful JSON and XML responses use a top-level envelope:
```json
{
"data": {}
}
```
When no latest/current resource exists, the request still succeeds and returns
`data: null`.
Text responses are rendered from endpoint-specific templates. When the payload
is missing, the templates return a short no-data message.
## Formats
Every implemented endpoint can produce:
| Format | Media type |
| --- | --- |
| `json` | `application/json` |
| `xml` | `application/xml` |
| `text` | `text/plain` |
Format selection is:
1. `format` query parameter;
2. `Accept` header;
3. configured `server.default_format`.
`format` values are case-insensitive. Unsupported formats return `406
Not Acceptable` with an error envelope.
## Shared Query Rules
Unknown query parameters are rejected with `400 Bad Request`.
| Parameter | Values | Default | Supported on |
| --- | --- | --- | --- |
| `format` | `json`, `xml`, `text` | configured default | all endpoints |
| `units` | `metric`, `us` | `metric` | all endpoints |
| `precision` | integer `0` through `2` | `0` | observations, current conditions, forecasts |
| `tz` or `TZ` | timezone selector | UTC/no conversion | forecasts, discussions, weather stories, outlooks |
`units`, `format`, and `precision` values are normalized case-insensitively
where applicable. `units=metric` returns metric field names; `units=us` returns
US-customary field names for unit-bearing payloads. Alerts, discussions, and
weather stories accept `units` but their current payload fields are not
materially changed by it. Outlooks also accept `units` without changing payload
values or field names.
`precision` controls numeric rounding. The default `0` rounds to whole numbers.
`precision` is rejected on alerts, discussions, weather stories, and outlooks.
Timezone selectors accepted by `tz` / `TZ`:
- IANA timezone names such as `America/Chicago`;
- common US abbreviations such as `CDT`, `CST`, `EDT`, `EST`, `MDT`, `MST`,
`PDT`, and `PST`;
- UTC offsets in `+H`, `+HH`, `+HH:MM`, `-H`, `-HH`, or `-HH:MM` form, bounded
to `-14:00` through `+14:00`;
- aliases `Chicago` and `Stl`, both mapped to `America/Chicago`.
If both `tz` and `TZ` are provided, their values must match
case-insensitively. Timezone conversion affects rendered timestamps and forecast
`/today` and `/tomorrow` day-slice filtering. Without a timezone parameter,
day-slice routes use UTC.
## Errors
API errors use a stable error envelope:
```json
{
"error": {
"code": "invalid_parameter",
"message": "..."
}
}
```
Implemented API error statuses:
| Status | Code | Cause |
| --- | --- | --- |
| `400 Bad Request` | `invalid_parameter` | unknown query parameter, invalid parameter value, invalid timezone, conflicting `tz` / `TZ`, or unsupported parameter on a route |
| `406 Not Acceptable` | `unsupported_format` | requested response format is not supported by the endpoint/renderers |
Unhandled service or database errors are returned as server errors by the
runtime.
## Behavior Not Implemented
`weatherapi` does not implement pagination, cache-control headers, rate-limit
headers, idempotency keys, retries, writes, or historical browsing outside the
implemented latest-resource and forecast day-slice routes.
## Endpoints
### Observations
```http
GET /observations
```
Returns the latest weather observation.
Query parameters: `format`, `units`, `precision`.
Metric `data` fields:
| Field | Type | Notes |
| --- | --- | --- |
| `stationId`, `stationName` | string | optional |
| `timestamp` | RFC3339 datetime | required when `data` is not null |
| `conditionCode` | integer | WMO weather code |
| `isDay` | boolean | optional |
| `textDescription` | string | optional |
| `temperatureC`, `dewpointC`, `apparentTemperatureC` | number | optional |
| `windDirectionDegrees`, `windSpeedKmh`, `windGustKmh` | number | optional |
| `barometricPressurePa`, `visibilityMeters` | number | optional |
| `relativeHumidityPercent` | number | optional |
| `presentWeather` | array | optional |
US mode replaces unit-bearing fields with `temperatureF`, `dewpointF`,
`apparentTemperatureF`, `windSpeedMph`, `windGustMph`,
`barometricPressureInHg`, and `visibilityMiles`. Direction and percentage
fields keep the same names.
Example:
```http
GET /observations?units=us&precision=1
```
### Current Conditions
```http
GET /conditions/current
```
Returns current conditions aggregated from recent `observations` rows. The
implemented observation window is 30 minutes.
Query parameters: `format`, `units`, `precision`.
Common `data` fields:
| Field | Type | Notes |
| --- | --- | --- |
| `conditionText` | string | optional text derived from WMO code and day/night flag |
| `isDay` | boolean | optional |
| `relativeHumidityPercent` | number | optional |
| `windDirectionDegrees` | number | optional |
Metric fields: `temperatureC`, `apparentTemperatureC`, `dewpointC`,
`windSpeedKmh`.
US fields: `temperatureF`, `apparentTemperatureF`, `dewpointF`,
`windSpeedMph`.
Example:
```http
GET /conditions/current?format=json&precision=0
```
### Active Alerts
```http
GET /alerts/active
```
Returns the latest stored alert run filtered to alerts active at request time.
Query parameters: `format`, `units`.
When no latest alert run exists, `data` is null. When a latest run exists but
no alerts are currently active, `data` remains an object and `alerts` is an
empty array.
Run `data` fields:
| Field | Type | Notes |
| --- | --- | --- |
| `locationId`, `locationName` | string | optional |
| `asOf` | RFC3339 datetime | required when `data` is not null |
| `latitude`, `longitude` | number | optional |
| `alerts` | array | active alerts, possibly empty |
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
at or before request time, and `expires` is absent or after request time.
`onset` is presented when available but is not used as the active boundary.
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
`senderName`, and `references`. Most alert fields are optional except `id` when
an alert item is present.
Reference fields are `id`, `identifier`, `sender`, and `sent`.
Example:
```http
GET /alerts/active?format=text
```
### Convective Outlooks
```http
GET /outlooks/convective
GET /outlooks/convective/active
```
Returns the latest SPC convective outlook run reconstructed from
weatherfeeder-owned `weather.outlook.v2` Postgres tables.
Route behavior:
- `/outlooks/convective` returns the latest run with stored location-filtered
outlook polygons unless user filters are supplied.
- `/outlooks/convective/active` adds an active-time filter using the server's
current UTC time. Outlooks are active when `validFrom <= now < validTo`.
When no latest run exists, `data` is null. When a run exists but filters match
no outlooks, `data` remains an object and `outlooks` and `discussions` are
empty arrays. Outlook endpoints use latest-run semantics and do not accumulate
historical active outlooks across older runs.
Query parameters:
| Parameter | Supported on | Values |
| --- | --- | --- |
| `format`, `units`, `tz` / `TZ` | all outlook routes | shared rules above |
| `day` | all outlook routes | `1`, `2`, or `3` |
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
outlooks are already filtered for the configured location. `precision`,
`containsLocation`, and unknown parameters are rejected.
Run `data` fields:
| Field | Type | Notes |
| --- | --- | --- |
| `locationId`, `locationName` | string | optional |
| `latitude`, `longitude` | number | optional |
| `asOf` | RFC3339 datetime | required when `data` is not null |
| `issuedAt` | RFC3339 datetime | optional |
| `outlooks` | array | ordered outlook polygons, possibly empty |
| `discussions` | array | ordered day-level discussions, possibly empty |
Outlook fields:
| Field | Type | Notes |
| --- | --- | --- |
| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present |
| `day` | integer | SPC outlook day |
| `labelText`, `forecaster` | string | optional |
| `severityRank` | integer | optional |
| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present |
| `sourceUrl`, `imageUrl` | string | optional |
| `containsLocation` | boolean | whether the outlook polygon contains the configured location |
| `geometry` | GeoJSON | stored outlook geometry |
Discussion fields:
| Field | Type | Notes |
| --- | --- | --- |
| `day` | integer | SPC outlook day |
| `headline`, `summary`, `discussion` | string | optional |
| `updatedAt` | RFC3339 datetime | optional |
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`, and discussion
`updatedAt`. Active filtering compares instants and is not changed by the
presentation timezone. Endpoint filters also filter `discussions` to days
represented by retained outlooks.
Examples:
```http
GET /outlooks/convective?day=1&outlookType=categorical
GET /outlooks/convective/active?format=text&tz=CDT
```
Example JSON response:
```json
{
"data": {
"locationId": "stl",
"locationName": "St. Louis",
"asOf": "2026-06-11T18:00:00Z",
"issuedAt": "2026-06-11T17:00:00Z",
"outlooks": [
{
"id": "spc-day1-cat-slight",
"provider": "spc",
"product": "convective",
"day": 1,
"outlookType": "categorical",
"label": "SLGT",
"labelText": "Slight Risk",
"severityRank": 5,
"validFrom": "2026-06-11T18:00:00Z",
"validTo": "2026-06-12T12:00:00Z",
"issuedAt": "2026-06-11T17:00:00Z",
"expiresAt": "2026-06-12T12:00:00Z",
"containsLocation": true,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-91.0, 38.0],
[-90.0, 38.0],
[-90.0, 39.0],
[-91.0, 38.0]
]
]
}
}
],
"discussions": [
{
"day": 1,
"headline": "Severe storms possible",
"summary": "Scattered severe storms are possible.",
"discussion": "SPC discussion text.",
"updatedAt": "2026-06-11T17:30:00Z"
}
]
}
}
```
Text format uses the shared convective outlook template for both outlook routes
and renders a no-data message when `data` is null.
### Forecasts
```http
GET /forecast/hourly
GET /forecast/hourly/today
GET /forecast/hourly/tomorrow
GET /forecast/narrative
GET /forecast/narrative/today
GET /forecast/narrative/tomorrow
```
Returns the latest hourly or narrative forecast run. `/today` and `/tomorrow`
return a copy of the latest run with `periods` filtered by each period's
`startTime` in the resolved timezone. If no periods match, `data` remains an
object and `periods` is an empty array.
Query parameters: `format`, `units`, `precision`, `tz` / `TZ`.
Run `data` fields:
| Field | Type | Notes |
| --- | --- | --- |
| `locationId`, `locationName` | string | optional |
| `issuedAt` | RFC3339 datetime | required when `data` is not null |
| `updatedAt` | RFC3339 datetime | optional |
| `product` | string | `hourly` or `narrative` for implemented routes |
| `latitude`, `longitude` | number | optional |
| `elevationMeters` or `elevationFeet` | number | optional, depends on `units` |
| `periods` | array | ordered forecast periods |
Metric period fields:
`startTime`, `endTime`, `name`, `isDay`, `conditionCode`,
`textDescription`, `temperatureC`, `temperatureCMin`, `temperatureCMax`,
`dewpointC`, `relativeHumidityPercent`, `windDirectionDegrees`,
`windSpeedKmh`, `windGustKmh`, `barometricPressurePa`, `visibilityMeters`,
`apparentTemperatureC`, `cloudCoverPercent`,
`probabilityOfPrecipitationPercent`, `precipitationAmountMm`,
`snowfallDepthMm`, and `uvIndex`.
US mode uses the same non-unit fields and replaces unit-bearing fields with
`temperatureF`, `temperatureFMin`, `temperatureFMax`, `dewpointF`,
`windSpeedMph`, `windGustMph`, `barometricPressureInHg`, `visibilityMiles`,
`apparentTemperatureF`, `precipitationAmountIn`, `snowfallDepthIn`, and
`elevationFeet` at run level.
`startTime` and `endTime` are required for each period. Other period fields are
optional, including `conditionCode`; narrative forecasts may omit it.
Examples:
```http
GET /forecast/hourly?units=us&precision=1&TZ=-5
GET /forecast/narrative/tomorrow?format=text&tz=Chicago
```
### Forecast Discussions
```http
GET /discussion
GET /discussion/key-messages
GET /discussion/short-term
GET /discussion/long-term
```
Returns the latest forecast discussion or a focused subresource.
Query parameters: `format`, `units`, `tz` / `TZ`.
Full discussion `data` fields:
| Field | Type | Notes |
| --- | --- | --- |
| `officeId`, `officeName` | string | optional |
| `product` | string | currently `afd` from weatherfeeder data |
| `issuedAt` | RFC3339 datetime | required when `data` is not null |
| `updatedAt` | RFC3339 datetime | optional |
| `keyMessages` | array of strings | optional |
| `shortTerm`, `longTerm` | object | optional section objects |
Discussion section fields are `qualifier`, `issuedAt`, and `text`, all
optional.
Subresources return the same metadata plus only their focused field:
`keyMessages`, `shortTerm`, or `longTerm`.
Example:
```http
GET /discussion/short-term?format=text&tz=CDT
```
### Weather Stories
```http
GET /weatherstories
GET /weatherstories/latest
```
`/weatherstories` returns the latest weather-story run and its ordered stories.
`/weatherstories/latest` returns the latest individual story.
Query parameters: `format`, `units`, `tz` / `TZ`.
Run `data` fields:
| Field | Type | Notes |
| --- | --- | --- |
| `officeId` | string | optional |
| `asOf` | RFC3339 datetime | required when `data` is not null |
| `stories` | array | ordered story objects |
Story fields:
| Field | Type | Notes |
| --- | --- | --- |
| `officeId` | string | optional |
| `startTime`, `endTime`, `updatedAt` | RFC3339 datetime | required when a story is present |
| `title`, `description`, `altText`, `downloadUrl` | string | optional |
| `priority` | boolean | required when a story is present |
| `order` | integer | required when a story is present |
Examples:
```http
GET /weatherstories?tz=America/Chicago
GET /weatherstories/latest?format=xml
```
## Copyable Requests
See [`examples/requests.http`](../examples/requests.http) for a compact set of
requests covering the implemented endpoint families.

69
docs/cli.md Normal file
View File

@@ -0,0 +1,69 @@
# weatherapi CLI
## Shortest Useful Command
```sh
go run ./cmd/weatherapi -config config.yml
```
This starts the HTTP API with the supplied YAML configuration. The configured
Postgres database must be reachable, and `templates.base_dir` must point to the
text response templates when text output is used.
## Command Overview
`weatherapi` is the service executable in `cmd/weatherapi`. It loads
configuration, opens configured database handles, selects the first configured
database as the primary weather data store, registers HTTP endpoints, and starts
the feedapi HTTP runtime.
Build and run a local binary:
```sh
go build -o ./weatherapi ./cmd/weatherapi
./weatherapi -config config.yml
```
Run with the default config path:
```sh
./weatherapi
```
## Flag Reference
| Flag | Default | Description |
| --- | --- | --- |
| `-config` | `WEATHERAPI_CONFIG` when set, otherwise `config.yml` | Path to the YAML config file. |
`weatherapi` does not currently expose other CLI flags.
## Environment Variables
| Variable | Description |
| --- | --- |
| `WEATHERAPI_CONFIG` | Default config path used when `-config` is not provided and the value is not blank. |
Command-line flags take precedence over environment defaults.
## Config Path Precedence
1. `-config /path/to/config.yml`
2. non-blank `WEATHERAPI_CONFIG`
3. `config.yml` in the current working directory
## Startup and Shutdown
Startup fails if configuration cannot be loaded, no database is configured, a
configured database cannot be opened, the primary database cannot be selected,
or the HTTP app cannot be constructed.
The process listens for `SIGINT` and `SIGTERM`. When a signal is received, the
runtime context is canceled and feedapi performs graceful HTTP shutdown. Database
close errors during shutdown are logged.
## Related Docs
- [`docs/config.md`](config.md): YAML configuration reference
- [`docs/api.md`](api.md): public HTTP API reference
- [`docs/operations.md`](operations.md): startup, shutdown, and deployment guidance

132
docs/config.md Normal file
View File

@@ -0,0 +1,132 @@
# weatherapi Configuration
`weatherapi` uses a feedapi YAML config file. This file configures the HTTP
server, database handles, and text template directory used by the service.
## Discovery
The executable chooses the config path in this order:
1. `-config /path/to/config.yml`
2. non-blank `WEATHERAPI_CONFIG`
3. `config.yml`
See [`docs/cli.md`](cli.md) for command examples.
## Minimal Config
```yaml
server:
listen_addr: ":8080"
default_format: json
databases:
- name: weatherdb
driver: postgres
uri: postgres://localhost:5432/weatherdb?sslmode=disable
username: weatherapi
password: change-me
templates:
base_dir: templates
```
A maintained copy is available at
[`examples/config.minimal.yml`](../examples/config.minimal.yml).
## Production-Oriented Config
Use explicit server timeouts, connection pool settings, and secret placeholders
for production deployments:
```yaml
server:
listen_addr: ":8080"
default_format: json
read_timeout: 5s
write_timeout: 10s
idle_timeout: 120s
databases:
- name: weatherdb
driver: postgres
uri: postgres://postgres.example.internal:5432/weatherdb?sslmode=require
username: weatherapi
password: ${WEATHERAPI_DB_PASSWORD}
max_open_conns: 10
max_idle_conns: 5
conn_max_lifetime: 30m
conn_max_idle_time: 5m
templates:
base_dir: templates
```
A maintained copy is available at
[`examples/config.production.yml`](../examples/config.production.yml).
## Reference
### `server`
| Field | Required | Default | Description |
| --- | --- | --- | --- |
| `listen_addr` | No | `:8080` | Address passed to the HTTP server. |
| `default_format` | No | `json` | Response format used when neither `format` nor `Accept` selects one. Supported values are `json`, `xml`, and `text`. |
| `read_timeout` | No | feedapi default | HTTP server read timeout. |
| `write_timeout` | No | feedapi default | HTTP server write timeout. |
| `idle_timeout` | No | feedapi default | HTTP keep-alive idle timeout. |
### `databases`
`databases` must contain at least one entry. `weatherapi` opens all configured
databases and uses the first entry as the primary weather data store for all
implemented reads.
| Field | Required | Description |
| --- | --- | --- |
| `name` | Yes | Database handle name. The first configured name is selected as primary by `cmd/weatherapi`. |
| `driver` | Yes | Database driver. The implemented deployment uses `postgres`. |
| `uri` | Yes | PostgreSQL connection URI. |
| `username` | Yes | Database username. |
| `password` | Yes | Database password. Use secret injection in real deployments. |
| `max_open_conns` | No | Maximum open connections for the database pool. |
| `max_idle_conns` | No | Maximum idle connections for the database pool. |
| `conn_max_lifetime` | No | Maximum lifetime for pooled connections. |
| `conn_max_idle_time` | No | Maximum idle time for pooled connections. |
The database must already contain the weatherfeeder-owned tables read by
`weatherapi`. This service does not ingest weather data and does not create or
migrate those tables.
### `templates`
| Field | Required | Description |
| --- | --- | --- |
| `base_dir` | Yes | Directory containing the `*.txt.tmpl` files used for text responses. |
The repository includes templates under [`templates/`](../templates/). The
Docker image copies this directory to `/weatherapi/templates` and runs with
`/weatherapi` as the working directory.
## Validation and Defaults
Configuration is loaded by feedapi. `cmd/weatherapi` adds one local validation
rule: at least one database entry is required.
Documented server defaults come from feedapi. Unknown YAML fields are not
documented as rejected by `weatherapi`; treat unrecognized fields as unsupported
configuration.
## Secrets
Do not commit production passwords, tokens, private hostnames, or private
connection strings. The examples use placeholders. Inject real values through
your deployment tooling before starting the service.
## Related Docs
- [`docs/cli.md`](cli.md): command-line usage and config path precedence
- [`docs/api.md`](api.md): HTTP format negotiation and API behavior
- [`docs/operations.md`](operations.md): deployment and runtime guidance
- [`docs/integrations/feedapi.md`](integrations/feedapi.md): feedapi config and runtime boundaries

View File

@@ -0,0 +1,140 @@
# Feedapi Runtime Contract
`weatherapi` uses feedapi as its generic HTTP runtime and configuration layer.
This document describes the feedapi behavior that `weatherapi` relies on.
## Version
`go.mod` depends on:
- `gitea.maximumdirect.net/ejr/feedapi v0.1.0`
Only feedapi behavior used by `weatherapi` is documented here.
## Packages Used
Runtime composition imports:
- `feedapi/app`
- `feedapi/config`
- `feedapi/db`
The HTTP adapter imports:
- `feedapi/bind`
- `feedapi/endpoint`
- `feedapi/errors`
- `feedapi/render`
- `feedapi/response`
Tests also use:
- `feedapi/templates`
- `feedapi/transport/httpx`
## Config Ownership
Feedapi owns loading and validating the YAML config used by `weatherapi`.
`weatherapi` adds one local runtime check: `databases` must contain at least
one entry.
The implemented config areas used by `weatherapi` are:
- `server`: HTTP listen/default format/timeouts;
- `databases`: named database handles opened into a registry;
- `templates`: base directory for text templates.
The canonical config reference is [`docs/config.md`](../config.md).
## Database Registry
`cmd/weatherapi` calls feedapi `db.OpenAll` with configured databases and passes
the resulting registry into `feedapi/app.New`. It also selects the first
configured database name from the registry as the primary weather store.
Feedapi owns opening and closing database handles. `weatherapi` owns choosing
which opened handle is used by the Postgres repository.
## Endpoint Registry
`weatherapi` builds endpoint definitions with `httpapi.Definitions` and passes
them to feedapi through `feedapi/app.WithEndpoints`.
Feedapi owns:
- route adaptation;
- HTTP method/path matching;
- invoking endpoint binders;
- invoking endpoint handlers;
- rendering handler results.
Endpoint definitions remain owned by `internal/adapters/inbound/httpapi`.
## Renderers and Templates
Each implemented endpoint declares JSON, XML, and text output through feedapi
render formats. Text endpoints also name a template file.
Feedapi owns:
- renderer registration;
- format negotiation;
- template loading from `templates.base_dir`;
- applying templates to response envelopes.
`weatherapi` owns the template files under `templates/` and presenter output
shapes consumed by those templates.
## Content Negotiation
`weatherapi` relies on feedapi's negotiation order:
1. `format` query parameter;
2. `Accept` header;
3. configured default format.
Unsupported formats are exposed as structured API errors. See
[`docs/api.md`](../api.md) for the public HTTP contract.
## Success and Error Envelopes
Endpoint handlers return `response.Envelope{Data: ...}` for successful
responses. Nil data is rendered as `data: null`.
Feedapi error handling exposes structured error envelopes with:
- `error.code`;
- `error.message`.
`weatherapi` relies on feedapi invalid-parameter and unsupported-format errors
for request validation and negotiation failures.
## Middleware and Shutdown
Feedapi owns generic HTTP middleware and server lifecycle. The architecture
policy records that recovery, request ID, and timing middleware are installed by
default.
`weatherapi` supplies a signal-cancelable context to feedapi startup. Feedapi
owns graceful HTTP shutdown after that context is canceled.
## Upgrade Checklist
Before upgrading feedapi:
- verify config field names and defaults still match [`docs/config.md`](../config.md);
- verify database registry behavior still supports first configured database
selection;
- verify endpoint definition APIs still support binders, handlers, formats,
and template names;
- verify negotiation order remains `format`, then `Accept`, then default;
- verify success and error envelopes still match [`docs/api.md`](../api.md);
- run `go test ./...` with private module access configured.
## Related Docs
- [`docs/internal/runtime.md`](../internal/runtime.md)
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
- [`docs/api.md`](../api.md)
- [`docs/config.md`](../config.md)
- [`docs/operations.md`](../operations.md)

View File

@@ -0,0 +1,202 @@
# Weatherfeeder Postgres Contract
`weatherapi` reads weather data from Postgres tables owned and populated by
`weatherfeeder`. This document describes only the storage contract consumed by
`weatherapi`; it is not a full weatherfeeder schema reference.
## Version
`go.mod` depends on:
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0`
The repository code also depends on weatherfeeder canonical model types. Table
compatibility must match the SQL in `internal/adapters/outbound/postgres`.
For convective outlooks, `weatherapi` assumes weatherfeeder's
`weather.outlook.v2` table reset has already been applied.
## Boundary
`weatherapi` is read-only:
- it does not create tables;
- it does not migrate tables;
- it does not ingest provider data;
- it does not write weatherfeeder events.
Weatherfeeder owns provider polling, normalization, table shape, and writes.
Postgres owns persistence, backup, restore, and availability.
## Table Families Read
| Resource | Tables read |
| --- | --- |
| Latest observation | `observations`, `observation_present_weather` |
| Current conditions | `observations` |
| Active alerts | `alert_runs`, `alerts`, `alert_references` |
| Hourly forecast | `forecasts`, `forecast_periods` |
| Narrative forecast | `forecasts`, `forecast_periods` |
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
| Weather story run | `weather_story_runs`, `weather_stories` |
| Latest weather story | `weather_stories` |
| Convective outlook run | `outlook_runs`, `outlooks`, `outlook_discussions` |
## Latest Row Selection
Latest parent resources use these ordering rules:
- observations: `observed_at DESC, event_emitted_at DESC`;
- alert runs: `as_of DESC, event_emitted_at DESC`;
- hourly forecasts: `product = 'hourly'`, then `issued_at DESC,
event_emitted_at DESC`;
- narrative forecasts: `product = 'narrative'`, then `issued_at DESC,
event_emitted_at DESC`;
- forecast discussions: `issued_at DESC, event_emitted_at DESC`;
- weather story runs: `as_of DESC, event_emitted_at DESC`;
- latest individual weather story: `updated_at DESC, as_of DESC,
story_order ASC, story_index ASC`.
- convective outlook runs: `as_of DESC, event_emitted_at DESC`.
Current conditions aggregate `observations` rows where `observed_at` is inside
the application-provided observation window.
## Child Ordering
Child rows are loaded separately and attached in stored order:
- observation present weather: `weather_index ASC`;
- alerts: `alert_index ASC`;
- alert references: `alert_index ASC, reference_index ASC`;
- forecast periods: `period_index ASC`;
- forecast discussion key messages: `message_index ASC`;
- weather stories for a run: `story_index ASC`;
- outlooks for a run: `outlook_index ASC`;
- outlook discussions for a run: `discussion_index ASC`.
## Columns Read
The repository reads only these columns.
### `observations`
`event_id`, `station_id`, `station_name`, `observed_at`, `condition_code`,
`is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
`apparent_temperature_c`, and `event_emitted_at`.
Current conditions additionally aggregate recent `observations` values for
temperature, apparent temperature, dewpoint, humidity, wind speed, wind
direction, condition code, and latest `is_day`.
### `observation_present_weather`
`weather_index`, `raw_text`, and `event_id`.
`raw_text` is decoded as JSON when present. Empty or null raw text maps to an
empty present-weather value; invalid JSON is returned as a repository error.
### `alert_runs`
`event_id`, `location_id`, `location_name`, `as_of`, `latitude`, `longitude`,
and `event_emitted_at`.
### `alerts`
`alert_index`, `alert_id`, `event`, `headline`, `severity`, `urgency`,
`certainty`, `status`, `message_type`, `category`, `response`, `description`,
`instruction`, `sent`, `effective`, `onset`, `expires`, `area_description`,
`sender_name`, and `run_event_id`.
### `alert_references`
`alert_index`, `reference_index`, `id`, `identifier`, `sender`, `sent`, and
`run_event_id`.
### `forecasts`
`event_id`, `location_id`, `location_name`, `issued_at`, `updated_at`,
`product`, `latitude`, `longitude`, `elevation_meters`, and
`event_emitted_at`.
Only `product = 'hourly'` and `product = 'narrative'` are read by implemented
routes.
### `forecast_periods`
`period_index`, `start_time`, `end_time`, `name`, `is_day`,
`condition_code`, `text_description`, `temperature_c`, `temperature_c_min`,
`temperature_c_max`, `dewpoint_c`, `relative_humidity_percent`,
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
`barometric_pressure_pa`, `visibility_meters`, `apparent_temperature_c`,
`cloud_cover_percent`, `probability_of_precipitation_percent`,
`precipitation_amount_mm`, `snowfall_depth_mm`, `uv_index`, and
`run_event_id`.
### `forecast_discussions`
`event_id`, `office_id`, `office_name`, `issued_at`, `updated_at`, `product`,
`short_term_qualifier`, `short_term_issued_at`, `short_term_text`,
`long_term_qualifier`, `long_term_issued_at`, `long_term_text`, and
`event_emitted_at`.
### `forecast_discussion_key_messages`
`message_index`, `message_text`, and `run_event_id`.
### `weather_story_runs`
`event_id`, `office_id`, `as_of`, and `event_emitted_at`.
### `weather_stories`
`story_index`, `office_id`, `start_time`, `end_time`, `updated_at`, `title`,
`description`, `alt_text`, `priority`, `story_order`, `download_url`,
`run_event_id`, and `as_of`.
### `outlook_runs`
`event_id`, `location_id`, `location_name`, `latitude`, `longitude`, `as_of`,
`issued_at`, and `event_emitted_at`.
### `outlooks`
`outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`,
`label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`,
`expires_at`, `forecaster`, `source_url`, `image_url`, `contains_location`,
`geometry_json`, and `run_event_id`.
`geometry_json` is copied into response GeoJSON without parsing or
reserializing. It must contain valid JSON.
### `outlook_discussions`
`discussion_index`, `day`, `headline`, `summary`, `discussion`, `updated_at`,
and `run_event_id`.
## Nullability and Time Assumptions
The repository scans nullable columns with `sql.Null*` types and maps them to
nil pointers or omitted zero values depending on the canonical model field.
Timestamps returned by the repository are normalized to UTC. Presentation
timezone conversion happens later in HTTP presenters.
Missing latest parent rows return `nil, nil` from repository methods. HTTP
rendering exposes this as a successful response with `data: null`.
## Compatibility Checklist
Before changing weatherfeeder storage or upgrading the weatherfeeder module:
- compare table names and columns with `internal/adapters/outbound/postgres`;
- preserve latest-row ordering columns used by `weatherapi`;
- preserve child index columns used for ordering;
- preserve nullable behavior expected by row mappers;
- run `go test ./internal/adapters/outbound/postgres` and affected HTTP tests.
## Related Docs
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
- [`docs/operations.md`](../operations.md)
- [`docs/troubleshooting.md`](../troubleshooting.md)

View File

@@ -0,0 +1,179 @@
# HTTP Adapter
This document describes the internal HTTP adapter under
`internal/adapters/inbound/httpapi`. The public endpoint contract belongs in
[`docs/api.md`](../api.md).
## Purpose
The HTTP adapter turns feedapi route definitions into calls on the application
service boundary. It owns route registration, query binding, request validation,
forecast day-slice filtering, outlook active filter construction,
alert active-time selection, response envelopes, and template names.
## Inputs and Outputs
Inputs:
- feedapi HTTP requests;
- query parameters;
- an implementation of the adapter-local `Service` interface.
Outputs:
- `endpoint.Definition` values registered by runtime composition;
- `response.Envelope{Data: ...}` values for successful requests;
- typed feedapi errors for invalid query parameters;
- endpoint-specific template names for text rendering.
## Boundaries
The adapter may:
- define routes and supported response formats;
- bind and validate query parameters;
- call the `Service` interface;
- choose the presenter function for an endpoint;
- filter forecast copies for `/today` and `/tomorrow`;
- pass the current UTC instant to active-alert application filtering;
- construct outlook active filters.
The adapter must not:
- execute SQL;
- open databases;
- mutate repository-returned models in place;
- duplicate public API reference text;
- move unit conversion or timezone presentation out of presenters.
## Config Fields Used
The HTTP adapter does not read config directly. Feedapi applies server and
renderer configuration before requests reach these handlers. Template names are
declared in endpoint definitions, but `templates.base_dir` is loaded by feedapi.
## External Adapters Used
- `feedapi/endpoint` for route definitions.
- `feedapi/render` for declared output formats.
- `feedapi/response` for success envelopes.
- `feedapi/bind` and `feedapi/errors` for query validation failures.
- `presenter` package for payload shaping.
## State
The adapter has no durable state. `forecastNow`, `alertNow`, and `outlookNow`
are package-level state only to make time-dependent endpoint tests
deterministic. Do not add request caches or cross-request mutable state here.
## Route Registry
`Definitions(svc)` returns all implemented route definitions. It includes:
- observations;
- active alerts;
- current conditions;
- weather stories;
- convective outlooks;
- forecast discussions;
- hourly and narrative forecasts.
Each route uses `endpoint.GET`, declares JSON/XML/text production, and names a
text template. Feedapi owns routing, format negotiation, response rendering,
middleware, and error normalization after definitions are registered.
## Query Binding
Binder shapes include:
- `bindQuery`: `format` and `units`;
- `bindPrecisionQuery`: `format`, `units`, and `precision`;
- `bindForecastPrecisionQuery`: `format`, `units`, `precision`, and timezone;
- `bindTimezoneQuery`: `format`, `units`, and timezone.
- outlook binders: `format`, `units`, timezone, and outlook filters.
All binders use feedapi binding helpers with `RejectUnknown: true`. Supported
common query values are lowercased and trimmed before binding where applicable.
`precision` defaults to `0` and must be between `0` and `2`. Timezone parsing is
available only through binders used by forecast, discussion, weather story, and
outlook routes.
Outlook routes accept `day` and `outlookType`. `containsLocation` is a response
field only and is rejected as a request parameter.
## Timezone Parsing
Timezone parsing accepts:
- IANA names through `time.LoadLocation`;
- configured aliases such as `Chicago` and `Stl`;
- common US abbreviations handled as fixed zones;
- signed UTC offsets.
If both `tz` and `TZ` are provided, they must match case-insensitively.
Invalid timezone input is converted to a feedapi invalid-parameter error.
## Forecast Day Slices
Forecast base routes return the latest run unchanged except for presentation.
`/today` and `/tomorrow` routes call `filterForecastRunByDaySlice`.
Filtering behavior:
- nil runs remain nil;
- missing timezone means UTC;
- day selection is based on `forecastNow()` in the resolved timezone;
- periods are included when `period.StartTime` falls on the target local date;
- the run is shallow-copied and `Periods` is replaced with the filtered slice.
The package variable `forecastNow` exists so endpoint tests can make day-slice
behavior deterministic.
## Alert Active Time
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
the application service with `alertNow().UTC()` so active alert filtering uses
the request-time instant while remaining deterministic in endpoint tests.
## Outlook Filters
Outlook route filters are built at the HTTP boundary and passed to the
application service:
- `/outlooks/convective` uses only user-supplied filters;
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`.
The package variable `outlookNow` exists so endpoint tests can make active
filtering deterministic.
The application service returns filtered outlook copies and trims run-level
discussions to days represented by retained outlooks.
## Failure Behavior
Binder failures become feedapi invalid-parameter responses. Handler service
errors are returned unchanged to feedapi for runtime normalization. Nil service
payloads are presented as nil data, so response rendering can produce
`data: null`.
## Templates
Endpoint definitions bind template names only. Template loading and rendering
belong to feedapi. Template files live under `templates/` and are operator
configuration through `templates.base_dir`.
## Tests to Inspect Before Changing
- `internal/adapters/inbound/httpapi/endpoints_test.go`
- `internal/adapters/inbound/httpapi/presenter/payload_test.go` when changing
presenter interaction
- `docs/api.md` when query behavior, route behavior, or payload behavior changes
## Invariants
- Preserve strict unknown-query rejection.
- Keep route-specific query policy in binder functions.
- Keep endpoint handlers thin: bind, call service, present, envelope.
- Keep day-slice filtering in the HTTP adapter, not in the repository.
- Preserve top-level `data` envelopes and nil-data behavior.

View File

@@ -0,0 +1,166 @@
# Postgres Repository
This document describes the outbound read adapter under
`internal/adapters/outbound/postgres`. It documents repository behavior and
storage assumptions without duplicating full schema documentation.
## Purpose
The Postgres repository implements `internal/app.Repository` against
weatherfeeder-owned tables. It reconstructs latest weather resources from SQL
rows and returns canonical weatherfeeder model values or application read
models.
## Inputs and Outputs
Inputs:
- a primary `*sql.DB` selected by runtime composition;
- query contexts from application service calls;
- weatherfeeder-populated Postgres rows.
Outputs:
- latest observation, forecast, discussion, weather story, alert, convective
outlook, and current conditions read models;
- nil data with nil error when the latest resource does not exist;
- contextual errors for query, scan, iteration, and JSON decode failures.
## Boundaries
The repository may:
- own SQL text and query ordering;
- scan rows into adapter-local row structs;
- map SQL nulls to pointers or omitted zero values;
- normalize timestamps to UTC;
- reconstruct child slices in database order.
The repository must not:
- bind HTTP query parameters;
- perform unit conversion or response rounding;
- convert timestamps to requested presentation timezones;
- render templates;
- create or migrate weatherfeeder tables.
## Config Fields Used
The repository does not read config directly. Runtime composition supplies the
primary `*sql.DB` selected from the first configured database entry.
## External Adapters Used
- `database/sql` for query execution and nullable scan types.
- `github.com/lib/pq` through runtime driver registration.
- `weatherfeeder/model` for canonical return values.
- `internal/app` for the repository interface and current-conditions model.
## State
The repository stores only a database handle. It owns no durable data, no
migrations, no polling state, and no retry queue.
## Repository Contract
`Repository` stores a `*sql.DB` and satisfies `app.Repository`.
Every public method first checks that the repository and database are
configured. A nil repository or nil database returns `postgres repository is not
configured`.
Missing parent rows map to `nil, nil`. This is how HTTP endpoints can return
successful responses with `data: null`.
## Route-to-Query Mapping
- `LatestObservation`: latest row from `observations`, then present-weather
rows from `observation_present_weather`.
- `CurrentConditions`: aggregates recent rows from `observations` using the
application-provided observation window.
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
`alert_references`. This is the latest stored alert snapshot; active-time
filtering is performed by the application service.
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
then child `forecast_periods`.
- `LatestNarrativeForecast`: latest `forecasts` row where
`product = 'narrative'`, then child `forecast_periods`.
- `LatestForecastDiscussion`: latest row from `forecast_discussions`, then
child `forecast_discussion_key_messages`.
- `LatestWeatherStoryRun`: latest row from `weather_story_runs`, then child
`weather_stories`.
- `LatestWeatherStory`: latest individual row from `weather_stories`.
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
`outlooks` and `outlook_discussions`.
Latest parent rows are selected by descending weather timestamp and
`event_emitted_at` where that tie-breaker is available in the query.
## Child Loading and Ordering
Child queries preserve stored order:
- observation present weather by `weather_index`;
- alerts by `alert_index`;
- alert references by `alert_index`, then `reference_index`;
- forecast periods by `period_index`;
- discussion key messages by `message_index`;
- weather stories by `story_index`;
- outlooks by `outlook_index`;
- outlook discussions by `discussion_index`.
Alert references are attached after both alert and reference rows are loaded.
References are grouped by alert index and attached to their corresponding alert.
## Null and Timestamp Policy
Row structs use `sql.Null*` types for nullable columns. Mapper helpers convert:
- invalid strings to empty strings;
- invalid booleans, floats, times, and WMO codes to nil pointers;
- valid times to UTC.
Required parent timestamps are normalized to UTC directly in mappers. Optional
times go through `timePtr`, which also normalizes to UTC.
Current conditions return nil when the aggregate sample count is zero.
## JSON Decode Behavior
Observation present-weather rows store raw JSON text. Empty or null text maps
to an empty present-weather value. Invalid JSON returns a contextual decode
error with the weather index.
Outlook rows store `geometry_json` as compact GeoJSON text. The repository
validates and copies the JSON bytes into `json.RawMessage` without parsing or
reserializing the geometry.
## Failure Behavior
Repository methods wrap failures with operation context, for example:
- `query latest observation`
- `query forecast periods`
- `scan forecast period row`
- `iterate weather story rows`
- `decode observation present weather row`
This context should be preserved when adding new reads so operator logs and
tests identify the failing operation.
## Tests to Inspect Before Changing
- `internal/adapters/outbound/postgres/repository_test.go`
- `internal/app/service_test.go` for repository port expectations
- endpoint tests when no-data behavior or returned model shape affects HTTP
responses
## Invariants
- Keep SQL and row structs in the Postgres adapter.
- Keep weatherfeeder schema ownership outside `weatherapi`.
- Preserve `nil, nil` no-data behavior for missing latest parent rows.
- Preserve UTC normalization at the repository boundary.
- Preserve child ordering from stored index columns.
- Preserve contextual error wrapping for query, scan, iteration, and decode
failures.

148
docs/internal/presenters.md Normal file
View File

@@ -0,0 +1,148 @@
# Presenters and Templates
This document describes payload shaping in
`internal/adapters/inbound/httpapi/presenter` and its relationship to text
templates. Public response fields are documented in [`docs/api.md`](../api.md).
## Purpose
Presenters translate repository/application read models into response-ready
payloads. They own unit conversion, numeric rounding, timezone conversion,
optional field preservation, and copy semantics.
## Inputs and Outputs
Inputs:
- weatherfeeder model values returned by the repository;
- `app.CurrentConditions` values returned by the application service;
- requested unit mode;
- requested precision;
- optional timezone location.
Outputs:
- JSON/XML/text-ready payload structs or canonical model copies;
- nil payloads for nil inputs.
## Boundaries
Presenters may:
- convert metric values to US-customary response fields;
- round numeric values that are presentation values;
- convert timestamps to a requested timezone;
- copy slices and pointers before changing presentation values;
- add template-only helper fields when they are not serialized.
Presenters must not:
- execute SQL;
- call services or repositories;
- parse HTTP query parameters;
- load templates;
- change canonical repository models in place.
## Config Fields Used
Presenters do not read config directly. They receive unit, precision, and
timezone selections from HTTP binders. Text output depends indirectly on
`templates.base_dir` because feedapi loads templates from that configured
directory.
## External Adapters Used
- `weatherfeeder/model` for canonical weather payloads.
- `weatherfeeder/standards` for current-condition text from WMO codes.
- `internal/app` for the current-conditions read model.
- Feedapi templates indirectly consume presenter output during text rendering.
## State
Presenters are stateless. Helper functions allocate copied values and return
new payloads for each request.
## Unit Conversion
Metric mode generally returns weatherfeeder canonical model shapes or
metric-named fields. US mode uses explicit US response structs for observations
and forecasts and US-specific fields in current conditions.
Conversion constants live in `constants.go`:
- Celsius to Fahrenheit;
- kilometers per hour to miles per hour;
- meters to miles or feet;
- pascals to inches of mercury;
- millimeters to inches.
Non-unit fields such as percentages, directions, text, IDs, and ordering values
keep their existing values.
## Precision
`roundedPtr` and `roundFloat` round numeric presentation values. Precision `0`
rounds to whole numbers. Positive precision uses powers of ten. Nil numeric
pointers remain nil.
Latitude and longitude are copied but not rounded by forecast presenters.
## Timezone Conversion
`inLocationTime` and `inLocationTimePtr` convert timestamps only when a
timezone is supplied. Without a timezone, timestamps are preserved as returned
by the repository.
Timezone conversion is applied by forecast, discussion, weather story, and
outlook presenters. Observations and current conditions do not currently
receive timezone input from their routes.
## Optional Fields and Copy Semantics
Presenter helpers copy pointer values before changing them. Slices are copied
before inclusion where needed. This preserves nil/omitempty behavior and avoids
mutating repository-returned values.
Nil input payloads return nil. Endpoint handlers wrap those nil payloads in a
response envelope so renderers can produce `data: null`.
## Endpoint Families
- Observations: metric copy or US response shape, including present-weather
slice copy.
- Current conditions: single response shape with either metric or US unit
fields populated, plus a template-only day/night text helper.
- Alerts: pass-through of canonical alert runs, with nil preserved.
- Forecasts: metric copy or US response shape, period copy, unit conversion,
precision, and timezone conversion.
- Discussions: full or focused payload shapes, section copy, key-message copy,
timezone conversion.
- Weather stories: run/story copy and timezone conversion.
- Convective outlooks: canonical model copy, pointer and geometry copy,
run-level discussion copy, and timezone conversion for run, outlook, and
discussion timestamps. Outlook polygon prose is not handled by the presenter;
prose is carried by run-level discussions. `units` is accepted by routes but
ignored by the presenter because outlook fields are not unit-bearing.
## Templates
Text templates consume the same envelope data produced by presenters. Template
files live under `templates/` and are loaded by feedapi from `templates.base_dir`.
Presenter changes can break text output even when JSON and XML still compile.
Check template field references before renaming or removing presenter fields.
## Tests to Inspect Before Changing
- `internal/adapters/inbound/httpapi/presenter/payload_test.go`
- `internal/adapters/inbound/httpapi/endpoints_test.go` for rendered text and
envelope behavior
- affected template files under `templates/`
## Invariants
- Keep unit conversion and rounding out of handlers and repositories.
- Preserve nil pointer and optional field behavior.
- Copy before converting, rounding, or timezone-shifting values.
- Keep text-template helper fields out of JSON/XML when they are not API fields.
- Update `docs/api.md` and templates when payload shape changes.

105
docs/internal/runtime.md Normal file
View File

@@ -0,0 +1,105 @@
# Runtime Composition
This document describes how the `weatherapi` executable wires configuration,
database handles, application services, HTTP endpoints, renderers, and shutdown.
It is development-facing; operator commands belong in
[`docs/operations.md`](../operations.md), and configuration fields belong in
[`docs/config.md`](../config.md).
## Purpose
`cmd/weatherapi/main.go` is the composition root. It should stay thin and only
connect already-implemented packages. Endpoint logic, SQL, presentation logic,
and business read behavior belong outside `cmd`.
## Inputs and Outputs
Inputs:
- config path from `-config`, `WEATHERAPI_CONFIG`, or `config.yml`;
- feedapi YAML config containing `server`, `databases`, and `templates`;
- OS cancellation signals;
- database handles opened by feedapi.
Outputs:
- a configured feedapi HTTP server;
- registered `weatherapi` endpoint definitions;
- process logs for fatal startup errors and database close errors.
## Composition Flow
The executable:
1. sets standard logger flags with microsecond precision;
2. resolves the config path;
3. creates a context canceled by `os.Interrupt` or `SIGTERM`;
4. loads config with `feedapi/config.Load`;
5. requires at least one configured database;
6. opens all configured databases with `feedapi/db.OpenAll`;
7. selects the first configured database name as the primary store;
8. constructs `postgres.Repository` with the primary `*sql.DB`;
9. constructs `app.Service` over the repository;
10. builds HTTP endpoint definitions with `httpapi.Definitions`;
11. constructs a feedapi app with the DB registry and endpoints;
12. starts feedapi with the signal-aware context.
## Config Fields Used
Runtime composition uses:
- `server`: consumed by feedapi for HTTP runtime settings and default format;
- `databases`: opened by feedapi, with the first entry selected as primary;
- `templates`: consumed by feedapi for text-template loading.
Do not duplicate the config field reference here. Keep it in
[`docs/config.md`](../config.md).
## External Adapters Used
- `feedapi/config`: YAML loading.
- `feedapi/db`: database registry and lifecycle.
- `feedapi/app`: HTTP runtime construction and startup.
- `internal/adapters/outbound/postgres`: weather read repository.
- `internal/adapters/inbound/httpapi`: endpoint definition registry.
- `github.com/lib/pq`: Postgres driver registration through blank import.
## State and Lifecycle
`weatherapi` owns no durable weather state. Runtime state is limited to loaded
configuration, database pools, endpoint definitions, renderer/template
registries managed by feedapi, and the running HTTP server.
Database handles are closed with a deferred registry close. Close errors are
logged but do not change response behavior because they occur during shutdown.
## Failure Behavior
`run` wraps startup errors with operation context:
- `load config`
- `config.databases requires at least one entry`
- `open databases`
- `select primary database`
- `build app`
Errors returned by `a.Start(ctx)` are returned to `main`, which logs a fatal
`weatherapi failed: ...` message. Feedapi owns graceful HTTP shutdown after the
context is canceled.
## Tests to Inspect Before Changing
- `internal/app/service_test.go` for service wiring expectations.
- `internal/adapters/inbound/httpapi/endpoints_test.go` for endpoint registry
and runtime adapter expectations.
- Full `go test ./...` when runtime wiring, config behavior, or feedapi
integration changes.
## Invariants
- Keep `cmd/weatherapi` as composition code only.
- Preserve config path precedence: `-config`, `WEATHERAPI_CONFIG`, `config.yml`.
- Preserve first configured database as the primary weather store.
- Keep generic HTTP runtime behavior in feedapi.
- Keep endpoint definitions in the HTTP adapter.
- Keep SQL and row mapping in the Postgres adapter.

192
docs/operations.md Normal file
View File

@@ -0,0 +1,192 @@
# weatherapi Operations
`weatherapi` is a read-only HTTP service. It serves latest weather records from
Postgres tables populated by `weatherfeeder`; it does not ingest provider data,
create tables, or run database migrations.
## Runtime Model
At startup, the executable:
1. resolves the config path from `-config`, `WEATHERAPI_CONFIG`, or `config.yml`;
2. loads feedapi YAML configuration;
3. opens all configured database handles;
4. selects the first configured database as the primary weather data store;
5. registers HTTP endpoints and text templates with feedapi;
6. starts the HTTP server.
See [`docs/cli.md`](cli.md) for invocation details and [`docs/config.md`](config.md)
for configuration fields.
## Prerequisites
- A reachable PostgreSQL database.
- Weatherfeeder-owned weather tables already created and populated.
- A config file with at least one `databases` entry.
- The `templates/` directory when `format=text` responses are needed.
- Network access from the service process or container to Postgres.
The first database entry in the config is the only database used for weather
reads. Additional configured handles may be opened by feedapi, but `weatherapi`
selects the first entry as primary.
## Local Run
Use the checked-in local sample only when its database settings match your
environment:
```sh
go run ./cmd/weatherapi -config config.yml
```
For a local binary:
```sh
go build -o ./weatherapi ./cmd/weatherapi
./weatherapi -config config.yml
```
Keep the process working directory aligned with `templates.base_dir`. With the
checked-in config, run from the repository root so `templates` resolves to the
repository's template directory.
## Container Run
The Docker image copies these runtime files into `/weatherapi`:
- `/weatherapi/weatherapi`
- `/weatherapi/config.yml`
- `/weatherapi/templates`
The runtime working directory is `/weatherapi`, and the entrypoint is the
`weatherapi` binary. To use the image's bundled config and templates:
```sh
docker run --rm -p 8080:8080 weatherapi
```
To provide an external config file:
```sh
docker run --rm -p 8080:8080 \
-v "$PWD/examples/config.production.yml:/weatherapi/config.yml:ro" \
weatherapi
```
To use a different config path, append the CLI flag:
```sh
docker run --rm -p 8080:8080 \
-v "$PWD/config.yml:/config/weatherapi.yml:ro" \
weatherapi -config /config/weatherapi.yml
```
If you mount a custom template directory, make `templates.base_dir` point to the
mounted path.
The repository's Woodpecker image build uses Kaniko and passes private Gitea
credentials as build arguments so Go can download private modules during the
build.
## Database Dependency
`weatherapi` expects weatherfeeder-compatible tables for observations, current
conditions aggregation, active alerts, forecasts, forecast discussions, weather
stories, and convective outlooks. It only reads those tables.
Convective outlook endpoints require weatherfeeder's `weather.outlook.v2` table
shape, including `outlook_runs`, `outlooks`, and `outlook_discussions`. If
operators reset or recreate outlook tables during a weatherfeeder upgrade,
complete that weatherfeeder-side migration before starting `weatherapi`.
Operational ownership is split:
- `weatherfeeder` owns provider polling, normalization, writes, table creation,
and schema compatibility.
- PostgreSQL owns durable storage, backups, replication, and restore.
- `weatherapi` owns serving read-only HTTP responses from the configured
primary database.
When restoring from backup or replacing the database, verify that weatherfeeder
has resumed writes before treating stale `weatherapi` responses as an API
problem.
## Templates and Text Output
JSON and XML responses do not depend on text templates. `format=text` uses the
template named by each endpoint, with templates stored under `templates.base_dir`.
The repository includes templates for all implemented endpoint families:
observations, current conditions, active alerts, hourly forecasts, narrative
forecasts, forecast discussions, weather stories, and convective outlooks.
If text rendering fails or returns an unsupported-format error, verify:
- the process can read `templates.base_dir`;
- expected `*.txt.tmpl` files are present;
- the requested endpoint supports `format=text`, which all implemented
endpoints currently do.
## Startup and Shutdown
Startup failures are fatal and are logged with the prefix `weatherapi failed`.
Common failure contexts include:
- `load config`
- `config.databases requires at least one entry`
- `open databases`
- `select primary database`
- `build app`
The process handles `SIGINT` and `SIGTERM`. Feedapi performs graceful HTTP
shutdown after the runtime context is canceled. Database close errors during
shutdown are logged, but they occur after the service has already begun stopping.
## Verification
There is no dedicated health endpoint in `weatherapi`. Use an implemented read
endpoint as a practical service check:
```sh
curl -i 'http://localhost:8080/observations?format=json'
```
Interpretation:
- `200 OK` with `{"data": null}` means the service is running but no latest
observation is available.
- `200 OK` with a populated `data` object means the service and that read path
are working.
- `400 Bad Request` indicates request validation, not service health.
- `406 Not Acceptable` indicates format negotiation.
- `5xx` indicates a runtime, database, or handler failure.
For a broader smoke check, use the requests in
[`examples/requests.http`](../examples/requests.http).
## Logs
The executable uses Go's standard logger with date, time, and microseconds.
Startup and fatal runtime errors are written to standard error. Container
platforms should collect stdout and stderr from the process.
## Backup and Recovery Boundaries
Back up and restore PostgreSQL using normal database procedures. `weatherapi`
has no local durable weather state to back up.
After a database restore or failover:
1. confirm the configured database is reachable from the service;
2. confirm weatherfeeder-owned tables exist;
3. confirm weatherfeeder is writing fresh rows if current data is expected;
4. restart `weatherapi` if database connection settings changed;
5. verify with an implemented endpoint such as `/observations`.
## Related Docs
- [`docs/api.md`](api.md): HTTP contract and error envelope
- [`docs/config.md`](config.md): configuration reference
- [`docs/troubleshooting.md`](troubleshooting.md): symptom-oriented fixes
- [`docs/integrations/feedapi.md`](integrations/feedapi.md): runtime and rendering contracts
- [`docs/integrations/weatherfeeder-postgres.md`](integrations/weatherfeeder-postgres.md): storage contract assumptions

196
docs/policy/architecture.md Normal file
View File

@@ -0,0 +1,196 @@
# Architecture Policy
## Purpose
This document defines `weatherapi`'s development architecture and invariants for maintainers and LLM coding agents. It describes how the implemented system is built and how future changes should preserve its boundaries.
This is an inward-facing policy document. Consumer-facing HTTP contract details belong in [`docs/api.md`](../api.md), and proposed or unimplemented work belongs under [`docs/roadmap/`](../roadmap/).
## Project Shape
`weatherapi` is a small Go HTTP API that serves weather data previously persisted by `weatherfeeder`. It does not poll upstream weather providers. It reads weatherfeeder-owned Postgres tables, maps rows back into weatherfeeder canonical model types or API-specific read models, and presents those resources through HTTP endpoints.
The implemented runtime flow is:
1. `cmd/weatherapi` resolves the config path from `-config`, then `WEATHERAPI_CONFIG`, then `config.yml`.
2. Feedapi loads YAML configuration for the HTTP server, database handles, and template directory.
3. The command opens configured databases and selects the first database as the primary weather data store.
4. The Postgres repository is constructed against the primary database.
5. The application service is constructed over the repository read port.
6. HTTP endpoint definitions are registered with feedapi.
7. Feedapi handles routing, middleware, format negotiation, template rendering, and graceful shutdown.
8. Each request binds query parameters, calls an application use case, presents the result, and returns a `data` response envelope.
Core application code lives in `internal/app`. Inbound HTTP adapters live under `internal/adapters/inbound/httpapi`. Outbound Postgres reads live under `internal/adapters/outbound/postgres`. Text response templates live in `templates/`.
## Core Design Principles
- Hexagonal boundaries: HTTP, configuration, database handles, SQL, rendering, and templates are adapters around application read use cases.
- Read-only API posture: `weatherapi` reads persisted weather data; ingestion and table ownership belong to `weatherfeeder`.
- Explicit ports: `internal/app.Repository` is the outbound read port, and `internal/adapters/inbound/httpapi.Service` is the inbound service contract used by handlers.
- Thin composition root: `cmd/weatherapi` wires config, databases, repository, service, endpoints, and feedapi runtime; it should not contain endpoint, SQL, or presentation logic.
- Adapter-local policy: query validation belongs at the HTTP boundary; SQL row shape and null handling belong in the Postgres adapter; unit and timezone presentation belongs in presenter helpers.
- Current-behavior docs: outside roadmap files, document only implemented routes, config, storage assumptions, and runtime behavior.
- Standard-library-first: use the Go standard library where reasonable, with narrow dependencies for framework/runtime integration and database access.
## Architectural Boundaries
Core/application logic:
- `internal/app` defines read use cases, repository ports, and API-specific read models such as current conditions.
- Application code may depend on canonical `weatherfeeder/model` types because those are the persisted weather domain contract.
- Application code should not import HTTP, feedapi endpoint/render packages, SQL packages, or template packages.
Inbound HTTP adapter:
- `internal/adapters/inbound/httpapi` owns route definitions, query binding, request validation, day-slice filtering for forecast routes, and endpoint-specific handler wiring.
- `internal/adapters/inbound/httpapi/presenter` owns response payload shaping, metric/US conversions, rounding, timestamp timezone conversion, and copy semantics.
- Endpoint handlers should call the `Service` interface and return `response.Envelope{Data: ...}`. They should not execute SQL directly.
Outbound Postgres adapter:
- `internal/adapters/outbound/postgres` owns SQL text, row structs, row scanning, null conversion, UTC normalization, and reconstruction of nested canonical payloads from weatherfeeder tables.
- Repository methods return `nil, nil` for missing latest rows so HTTP responses can render `data: null`.
- The adapter assumes the weatherfeeder Postgres schema exists. It does not create or migrate weatherfeeder tables.
Runtime composition:
- `cmd/weatherapi/main.go` owns process wiring: config path selection, config loading, DB registry, primary DB selection, repository/service construction, endpoint registration, signal cancellation, and logging.
- Feedapi owns the generic HTTP runtime: routing, renderers, middleware, database registry lifecycle, and graceful server shutdown.
## Modules or Stages
The implemented request path has five main stages: route matching, query binding, service call, presentation, and rendering.
Query binding contract:
- Normalize common `format`, `units`, and `precision` query values where supported.
- Reject unknown query parameters on implemented endpoints.
- Keep endpoint-specific query rules in endpoint binders.
- Parse timezone only for route families that support `tz` / `TZ`.
- Return typed feedapi errors so invalid input becomes a structured `400 Bad Request` response.
Service contract:
- Keep service methods narrow and resource-specific.
- Delegate simple latest-resource reads directly to the repository.
- Keep use-case defaults in `internal/app`; current conditions use `ObservationWindowMinutesDefault`.
- Do not let transport or SQL details leak into service method signatures.
Presenter contract:
- Treat repository-returned models as immutable input.
- Copy payloads before unit conversion, rounding, or timezone conversion.
- Preserve nil pointers and optional fields so JSON/XML `omitempty` behavior remains meaningful.
- Keep metric and US field naming explicit and tested.
- Keep text templates conditional so absent optional values do not render misleading zero values.
Repository contract:
- Query the latest parent row for each resource, then load child rows in stored order where needed.
- Map SQL nulls to nil pointers or zero-value omitted fields consistently.
- Normalize database timestamps to UTC before returning models.
- Wrap query, scan, iteration, and decode errors with operation context.
## State, Inputs, and Outputs
Inputs are HTTP requests and Postgres rows written by `weatherfeeder`.
Outputs are HTTP responses in JSON, XML, or text format. All public endpoint handlers return a top-level `data` envelope; missing latest data is represented as `data: null`.
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
The API currently serves latest-resource views: latest observation, current conditions, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Active alert, forecast `today` and `tomorrow`, and convective outlook active routes derive filtered copies from the latest stored run.
## Configuration and CLI Boundaries
The executable supports one CLI flag:
- `-config`: path to the YAML config file.
If `-config` is not provided, the default comes from `WEATHERAPI_CONFIG`; if that environment variable is empty, the default is `config.yml`.
Configuration shape is provided by feedapi and includes:
- `server`: listen address, default format, and HTTP timeouts;
- `databases`: named database handles;
- `templates`: template base directory.
`cmd/weatherapi` requires at least one configured database and uses the first database as the primary weather repository. Template loading is handled by feedapi; text output is available when the configured template directory is present and loaded.
Do not duplicate full CLI or config reference material here. If `docs/cli.md` or `docs/config.md` are added or updated, they should be the canonical user/operator references.
## Errors, Logging, and Diagnostics
`cmd/weatherapi` uses the standard library `log` package with timestamps and microseconds.
Startup failures are fatal and include operation context such as config loading, database opening, primary database selection, app construction, or server startup. Database close errors during shutdown are logged.
Feedapi handles transport-level diagnostics:
- bind errors become structured invalid-parameter responses;
- unsupported formats become structured unsupported-format responses;
- handler errors are normalized before response rendering;
- recovery, request ID, and timing middleware are installed by default.
Repository methods should distinguish no data from errors. `sql.ErrNoRows` maps to `nil, nil`; query, scan, iteration, and JSON decode failures should return contextual errors.
The daemon handles `os.Interrupt` and `SIGTERM` through `signal.NotifyContext`. Feedapi starts `http.Server.ListenAndServe` and performs graceful shutdown when the context is canceled.
## Testing Expectations
When changing behavior, inspect or add focused tests in the owning package.
Expected coverage by change type:
- App service: repository delegation, use-case defaults, filtering or aggregation rules, and error propagation.
- HTTP adapter: route registration, query validation, supported/rejected parameters, format negotiation, error envelopes, null `data`, day-slice behavior, and timezone behavior.
- Presenter: metric/US conversion, rounding, timezone conversion, optional field omission, nil handling, copy semantics, and text-template-sensitive fields.
- Postgres adapter: SQL row mapping, nullable handling, UTC normalization, child ordering, missing rows, and nested payload reconstruction.
- Runtime/config: config path behavior, database selection, template loading, and endpoint registration when those surfaces change.
Use fakes, `httptest`, local renderers/templates, and row-mapping fixtures rather than live services. Full-package tests should remain fast and deterministic.
## Dependency Policy
Prefer the Go standard library for HTTP helpers, time handling, logging, tests, conversions, and small utilities.
Accepted project-level dependencies are narrow and purposeful:
- `feedapi` provides config loading, HTTP runtime, endpoint definitions, renderers, templates, middleware, and database registry behavior.
- `weatherfeeder` provides canonical weather model and standards types that match the persisted data contract.
- `github.com/lib/pq` provides the Postgres driver.
Do not add dependencies for small conveniences. Do not let dependency-specific types cross application boundaries unless that dependency is the explicit adapter contract.
## Documentation Expectations
Documentation must follow [`docs/policy/documentation.md`](documentation.md).
Rules for architecture-related docs:
- Current-behavior docs must describe implemented behavior only.
- Unimplemented work belongs only under `docs/roadmap/` until implemented.
- Prefer links to canonical docs over repeated reference material.
- Update docs in the same change when modifying public routes, query behavior, config behavior, runtime behavior, presenter payloads, or database read assumptions.
## Architectural Invariants
- Keep `cmd/weatherapi` as composition code, not endpoint, SQL, or presentation logic.
- Keep application read ports in `internal/app` independent of HTTP and SQL packages.
- Keep HTTP route definitions and query binding in the inbound adapter.
- Keep unit conversion, rounding, timezone presentation, and payload copy behavior in presenters.
- Keep SQL text, row structs, and null handling in the Postgres adapter.
- Preserve `data` envelope responses and `data: null` semantics for missing latest rows.
- Preserve UTC normalization at the repository boundary and timezone conversion at the presentation boundary.
- Preserve strict query-parameter rejection unless an endpoint explicitly allows a parameter.
- Preserve text templates as adapter presentation assets, not business logic.
- Do not make `weatherapi` responsible for ingesting upstream weather data or creating weatherfeeder tables.
## Non-Goals
- `weatherapi` is not a weather ingester and does not call NWS, Open-Meteo, OpenWeather, or SPC provider APIs.
- `weatherapi` does not own weatherfeeder's database schema creation or migrations.
- `weatherapi` does not provide historical browsing except where an implemented endpoint explicitly derives a filtered view from the latest run.
- `weatherapi` is not a general-purpose API framework; generic HTTP runtime behavior belongs to `feedapi`.
- Architecture policy is not a public endpoint, CLI, or config reference.

159
docs/policy/development.md Normal file
View File

@@ -0,0 +1,159 @@
# Development Policy
This document gives developers and LLM coding agents the concrete workflow for
changing `weatherapi` safely. Architecture invariants are defined in
[`docs/policy/architecture.md`](architecture.md).
## Repository Layout
| Path | Purpose |
| --- | --- |
| `cmd/weatherapi` | executable composition root |
| `internal/app` | read use cases, repository port, current-conditions model |
| `internal/adapters/inbound/httpapi` | route definitions, query binding, handlers |
| `internal/adapters/inbound/httpapi/presenter` | response payload shaping |
| `internal/adapters/outbound/postgres` | SQL reads and row mapping |
| `templates` | text response templates |
| `docs` | current documentation, policy, internals, integrations, roadmap |
| `examples` | copyable config and request examples |
## Build and Test Commands
Build:
```sh
go build ./cmd/weatherapi
```
Run all tests:
```sh
go test ./...
```
Focused tests:
```sh
go test ./internal/app
go test ./internal/adapters/inbound/httpapi
go test ./internal/adapters/inbound/httpapi/presenter
go test ./internal/adapters/outbound/postgres
```
Private module access is required for `feedapi` and `weatherfeeder` downloads.
If tests fail because Go cannot fetch a private module, fix module access before
treating package tests as behavior failures.
## Coding Conventions
- Keep `cmd/weatherapi` thin.
- Keep application use cases independent of HTTP, SQL, renderers, and templates.
- Keep query validation in the HTTP adapter.
- Keep unit conversion, rounding, timezone presentation, and payload copy
behavior in presenters.
- Keep SQL text, row structs, null handling, and UTC normalization in the
Postgres adapter.
- Prefer small focused changes over broad refactors.
- Preserve contextual error wrapping in repository and runtime code.
## Dependency Policy
Current direct dependencies are:
- `feedapi` for config, DB registry, endpoint definitions, renderers,
middleware, templates, and HTTP runtime;
- `weatherfeeder` for canonical model and standards types;
- `github.com/lib/pq` for the Postgres driver.
Do not add dependencies for small conveniences. New dependencies need a clear
adapter or domain purpose and should not leak through application boundaries
unless they are the explicit boundary contract.
## Adding or Changing Endpoints
1. Add or update the `Service` method in `internal/adapters/inbound/httpapi` if
the handler needs a new application read.
2. Add or update the application repository port in `internal/app`.
3. Implement the read in the Postgres adapter if needed.
4. Add the endpoint definition and binder in `internal/adapters/inbound/httpapi`.
5. Add presenter behavior in `presenter` instead of shaping payloads in handlers.
6. Add or update text templates when `format=text` should be supported.
7. Add endpoint tests for registration, query validation, formats, errors,
`data: null`, and representative payload behavior.
8. Update [`docs/api.md`](../api.md) and examples when the public HTTP contract changes.
## Changing Query Parameters
- Update binder functions and endpoint tests together.
- Preserve strict unknown-parameter rejection unless the route explicitly allows
a new parameter.
- Keep timezone parsing limited to route families that support it.
- Keep precision range validation aligned with presenter rounding support.
- Update [`docs/api.md`](../api.md) for public query behavior changes.
## Adding Config Fields
Config shape is loaded by feedapi. When `weatherapi` starts using a new config
field:
1. update runtime composition or the relevant adapter;
2. update [`docs/config.md`](../config.md);
3. update examples under `examples/` if operators need to set it;
4. add config/runtime tests where practical;
5. avoid committing real credentials or private infrastructure details.
## Adding CLI Flags
The executable currently supports only `-config`. If adding a flag:
1. keep parsing in `cmd/weatherapi`;
2. avoid putting business logic in `cmd`;
3. document precedence with environment variables if applicable;
4. update [`docs/cli.md`](../cli.md);
5. add tests when flag behavior is not trivial.
## Changing Repository Reads
- Keep SQL in `*_queries.go`.
- Keep row DTOs in `*_rows.go`.
- Keep mapping/null handling in `*_mapper.go`.
- Return `nil, nil` for missing latest parent rows.
- Normalize timestamps to UTC in mappers.
- Preserve child ordering from stored index columns.
- Update [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
and [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
when table or column assumptions change.
## Changing Presenters or Templates
- Copy input values before conversion, rounding, or timezone changes.
- Preserve nil pointer and `omitempty` behavior.
- Keep text-template helper fields out of JSON/XML when they are not public API
fields.
- Check `templates/*.txt.tmpl` for field references.
- Update presenter tests and endpoint text tests.
- Update [`docs/api.md`](../api.md) when response shape changes.
## Updating Examples
Examples must be copyable, valid, and free of secrets.
- Config examples belong under `examples/config.*.yml`.
- HTTP examples belong in `examples/requests.http`.
- Do not include unimplemented routes.
- Re-run YAML syntax checks or config loading tests when config examples change.
## Documentation Checklist
When behavior changes, update the canonical doc in the same change:
- README for project orientation and shortest useful command;
- `docs/api.md` for public HTTP behavior;
- `docs/config.md` for YAML config;
- `docs/cli.md` for executable flags and environment variables;
- `docs/operations.md` and `docs/troubleshooting.md` for operator behavior;
- `docs/internal/` for implementation boundaries;
- `docs/integrations/` for external storage/runtime contracts;
- `docs/roadmap/` only for unimplemented work.
Do not describe unimplemented behavior outside `docs/roadmap/`.

View File

@@ -0,0 +1,444 @@
# Go Project Documentation Policy
## Purpose
Project documentation must help five audiences:
1. users who need to run the application;
2. administrators/operators who need to configure and operate it;
3. developers who need to understand and change it safely;
4. LLM coding agents that need clear scope, boundaries, and invariants;
5. developers and LLM coding agents integrating this project from another codebase.
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
## Core Rules
### 1. Keep docs concise
Each document should cover a defined scope and only the essentials for that scope.
Avoid:
- long background explanations;
- repeated reference material;
- implementation detail in user-facing docs;
- aspirational language outside roadmap docs;
- verbose examples where one minimal example is clearer.
### 2. Document only implemented behavior outside roadmap files
Unimplemented, planned, aspirational, experimental, or future work may be described only under:
- `docs/roadmap/`
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
### 3. Use canonical homes
Each type of information should have one canonical location.
Canonical homes:
- project purpose and quickstart: `README.md`
- development principles: `docs/policy/architecture.md`
- public HTTP API reference: `docs/api.md`
- configuration reference: `docs/config.md`
- CLI reference: `docs/cli.md`
- operations and recovery: `docs/operations.md`
- troubleshooting: `docs/troubleshooting.md`
- public API/package consumer guidance: `docs/consumers/`
- implemented internals: `docs/internal/`
- external protocol, service, and file-format contracts: `docs/integrations/`
- future work: `docs/roadmap/`
- contributor workflow: `docs/policy/development.md`
- copyable examples: `examples/`
Other files should summarize briefly and link to the canonical source.
### 4. Keep examples real
Examples should be valid, maintained, and free of secrets.
Where practical:
- example configs should load successfully;
- example commands should match real CLI syntax;
- important examples should be covered by tests.
## Documentation Profiles
All projects require:
- `README.md`
- `docs/policy/architecture.md`
Additional docs depend on the project.
### Small library
Recommended:
- `docs/policy/development.md`, if contributor conventions are non-obvious
### Simple CLI
Required:
- `docs/cli.md`
Recommended:
- `docs/policy/development.md`
### Config-driven CLI
Required:
- `docs/cli.md`
- `docs/config.md`
Recommended:
- `examples/`
- `docs/policy/development.md`
### Stateful or operator-facing application
Required:
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
Recommended:
- `docs/troubleshooting.md`
- `examples/`
- `docs/policy/development.md`
### Modular, service-oriented, or orchestration application
Required:
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
- `docs/internal/`
- `docs/policy/development.md`
Recommended:
- `docs/troubleshooting.md`
- validated examples under `examples/`
### Public HTTP API service
Required:
- `docs/api.md`
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
- `docs/internal/`
- `docs/policy/development.md`
Recommended:
- `docs/troubleshooting.md`
- `docs/consumers/`, for task-oriented client integration guides
- `docs/integrations/`, for upstream/downstream service contracts
- validated examples under `examples/`
### Project with public packages or consumer APIs
Required:
- `docs/consumers/api.md`
- one `docs/consumers/pkg-<name>.md` file per public package, if public packages exist
Recommended:
- copyable consumer examples under `examples/`, if practical
## Required Documents
### README.md
**Audience:** users, administrators, operators
The README is the outward-facing project orientation page.
It should include, in order:
1. concise description;
2. elevator pitch;
3. shortest useful command or usage example;
4. links to targeted docs.
The README should be short. It is not a manual.
The “shortest useful command” means the simplest command that performs the projects core use case. (It does not mean `app --help`.)
### docs/policy/architecture.md
**Audience:** developers, LLM coding agents
`docs/policy/architecture.md` is required for every project.
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
It should include:
- project shape;
- core design principles;
- package and boundary philosophy;
- state/persistence philosophy, if applicable;
- external integration philosophy, if applicable;
- error-handling and logging principles;
- testing expectations;
- documentation expectations;
- architectural invariants;
- explicit non-goals, if useful.
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
### docs/api.md
**Audience:** external HTTP API consumers, developers, LLM coding agents integrating by HTTP
Required for projects whose primary public interface is HTTP.
`docs/api.md` is the canonical public HTTP API contract. It should be normative for external consumers and should not be duplicated by README, operations docs, consumer guides, or integration docs.
It should include:
1. base URL conventions;
2. authentication and authorization behavior, if implemented;
3. response envelope;
4. supported media types and content negotiation behavior;
5. shared query parameters;
6. endpoint reference grouped by route family;
7. request parameters and validation rules;
8. response fields, units, nullability, and optionality;
9. error response shape and status codes;
10. pagination, caching, rate-limit, idempotency, and retry behavior, if implemented;
11. compact request and response examples.
It must document only implemented endpoints and behavior. Planned endpoints, proposed fields, future filters, and experimental response shapes belong only under `docs/roadmap/`.
For HTTP API projects, `docs/consumers/` may provide task-oriented client integration guides, but those guides should link to `docs/api.md` for the authoritative endpoint contract.
### docs/policy/development.md
**Audience:** developers, LLM coding agents
Required for projects maintained by humans and LLM coding agents.
It should include:
- repository layout;
- build/test commands;
- coding conventions;
- dependency policy;
- how to add config fields;
- how to add CLI flags;
- how to add modules or adapters, if applicable;
- how to update examples;
- documentation update expectations.
### docs/config.md
**Audience:** administrators, operators, advanced users
Required for applications with configuration files.
It should include, in order:
1. config file locations and discovery precedence;
2. minimal working config;
3. production-oriented config;
4. full configuration reference;
5. secrets handling, if applicable;
6. links to maintained examples.
The full configuration reference should be canonical.
### docs/cli.md
**Audience:** users, administrators, operators
Required for CLI applications.
It should include, in order:
1. shortest useful command;
2. command overview;
3. complete flag reference;
4. common workflows;
5. diagnostic or recovery commands, if applicable.
Explain when commands are useful, not just their syntax.
### docs/operations.md
**Audience:** administrators, operators
Required for applications that maintain state, support resume behavior, run multi-step workflows, write durable artifacts, use remote storage, or require recovery procedures.
It should cover:
- normal workflow;
- filesystem layout;
- remote storage layout, if applicable;
- logs and manifests;
- resume/retry behavior;
- cleanup behavior;
- archive/backup behavior;
- safe recovery procedures;
- operational caveats.
### docs/troubleshooting.md
**Audience:** administrators, operators
Recommended once recurring failure modes exist.
Each entry should include:
- symptom;
- likely cause;
- diagnostic command or inspection step;
- safe fix;
- relevant links.
### docs/consumers/
**Audience:** developers and LLM coding agents integrating this project from another codebase
Required for projects with public packages, SDKs, client APIs, plugin APIs, or other application-facing integration surfaces.
This directory describes how an external codebase should consume the project's public API. It should be task-oriented and copyable where useful. It is not the place for internal implementation details or operator procedures.
For projects whose public API is HTTP, `docs/consumers/` is not required, and it should not duplicate the endpoint reference in `docs/api.md`. If present, it may provide practical integration workflows, client-specific examples, or migration notes that link back to `docs/api.md`.
`docs/consumers/api.md` should provide the consumer-facing overview and primary implementation workflow. It should include:
1. intended consumer audience and use cases;
2. required inputs supplied by operators or deployment configuration;
3. recommended public package or API workflow;
4. minimal copyable example;
5. consumer responsibilities and boundaries;
6. retry, idempotency, or status behavior, if applicable;
7. links to package-specific docs and canonical integration contracts.
Package-specific docs should be named `pkg-<name>.md` and should include:
1. import path;
2. intended use cases;
3. primary types and functions needed by consumers;
4. minimal examples;
5. validation, error, retry, and boundary behavior;
6. links to canonical file-format or wire-protocol contracts.
### docs/internal/
**Audience:** developers, LLM coding agents
Required for modular, service-oriented, or orchestration projects.
This directory describes implemented internal components. It is not the roadmap.
Use one file per major component where useful.
Each component doc should include:
1. purpose;
2. inputs and outputs;
3. boundaries;
4. config fields used;
5. external adapters used;
6. state or manifest behavior, if applicable;
7. skip/resume behavior, if applicable;
8. failure behavior;
9. tests to inspect before changing;
10. architectural invariants.
### docs/roadmap/
**Audience:** maintainers, developers, LLM coding agents
This is the only place for planned, future, aspirational, experimental, or unimplemented work.
Roadmap docs should clearly distinguish:
- proposed work;
- accepted plans;
- deferred ideas;
- rejected ideas;
- implementation prompts or task breakdowns, if useful.
Roadmap docs should not be confused with current behavior.
### docs/integrations/
**Audience:** developers, LLM coding agents
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses or exposes.
For public HTTP API services, `docs/integrations/` should document upstream, downstream, storage, protocol, or runtime contracts that the service depends on or bridges. It should not become a second copy of the public HTTP endpoint reference; that belongs in `docs/api.md`.
Use one file per integration where useful.
## Examples Directory
Projects with non-trivial configuration or workflows should include `examples/`.
Useful examples include:
- minimal working config;
- production-oriented config;
- full annotated config;
- local development config;
- remote/object-storage config;
- minimal session/input file.
Examples should be valid, maintained, tested when practical, and linked from relevant docs.
## Security and Privacy
Docs and examples must not include:
- real API keys;
- tokens;
- passwords;
- private keys;
- private environment dumps;
- sensitive user data;
- raw private transcripts;
- private infrastructure details unless intentionally public.
Document secret-handling mechanisms, not actual secret values.
## Maintenance Rules
When docs change, verify the affected behavior.
Where practical:
- load example config files in tests;
- test CLI examples or command parser behavior;
- validate documented flags against real flags;
- remove stale references;
- update links after renames;
- keep roadmap content out of non-roadmap docs.
If documentation and code disagree, fix the documentation and/or open a roadmap item; do not leave aspirational behavior in current-behavior docs.
Documentation is complete only when it matches the current code.
## Documentation Change Checklist
Before merging documentation changes, verify:
- README is concise and orientation-focused.
- `docs/policy/architecture.md` describes development principles.
- `docs/api.md` is the canonical HTTP contract for HTTP API services.
- Future work appears only under `docs/roadmap/`.
- User-facing docs avoid unnecessary internals.
- Consumer-facing docs explain public APIs without duplicating HTTP endpoint or integration contracts.
- Developer-facing docs preserve boundaries and invariants.
- Config examples match the schema.
- CLI examples match real commands and flags.
- Defaults appear in the canonical config reference.
- No secrets or private data are included.
- Links are accurate.

281
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,281 @@
# weatherapi Troubleshooting
Use this guide to separate startup, database, template, request-validation, and
no-data problems. For full command and configuration reference, see
[`docs/cli.md`](cli.md) and [`docs/config.md`](config.md).
## Startup Fails with `load config`
Symptom: the process exits and logs `weatherapi failed: load config: ...`.
Likely causes:
- the config path is wrong;
- the process working directory is not where `config.yml` is expected;
- YAML syntax is invalid;
- required feedapi config fields are missing.
Diagnostic:
```sh
ls -l config.yml
python3 -c 'import yaml; yaml.safe_load(open("config.yml"))'
```
Safe fix: pass the intended file explicitly with `-config`, or set a non-blank
`WEATHERAPI_CONFIG`. Fix YAML syntax and compare the file with
[`examples/config.minimal.yml`](../examples/config.minimal.yml).
## Startup Fails with `config.databases requires at least one entry`
Symptom: the process exits before opening any database.
Likely cause: `databases` is missing or empty in the YAML file.
Diagnostic:
```sh
rg -n '^databases:' config.yml
```
Safe fix: add at least one database entry. The first entry is the primary
weather data store used for reads.
## Startup Fails with `open databases`
Symptom: the process exits while opening configured database handles.
Likely causes:
- PostgreSQL host or port is unreachable;
- credentials are invalid;
- the database name in `uri` is wrong;
- TLS/`sslmode` settings do not match the server;
- the configured driver is not usable for this deployment.
Diagnostic:
```sh
psql 'postgres://USER:PASSWORD@HOST:5432/DBNAME?sslmode=disable' -c 'select 1'
```
Safe fix: correct `uri`, `username`, `password`, network routing, firewall
rules, or TLS settings. Keep production secrets out of committed config files.
## Startup Fails with `select primary database`
Symptom: databases open, then the process exits selecting the primary database.
Likely cause: feedapi opened a registry that does not contain the first
configured database name.
Diagnostic: inspect the first `databases` item in the active config and verify
that `name` is present and non-empty.
Safe fix: give the first database entry a stable `name` and keep it as the
weather database entry.
## Startup Fails with `build app`
Symptom: the process exits after database setup but before serving HTTP.
Likely causes:
- feedapi cannot construct the HTTP app from the config;
- renderer or template setup failed;
- endpoint registration failed.
Diagnostic: read the error text after `build app:`. If the error mentions
templates, inspect `templates.base_dir`.
Safe fix: correct the config value mentioned by the error. For template errors,
make the directory readable and ensure the repository's `*.txt.tmpl` files are
present.
## Requests Return `400 invalid_parameter`
Symptom: JSON error body includes:
```json
{
"error": {
"code": "invalid_parameter",
"message": "..."
}
}
```
Likely causes:
- unknown query parameter;
- `precision` outside `0` through `2`, or not an integer;
- `precision` used on alerts, discussions, weather stories, or outlooks;
- `tz` used on observations, current conditions, or alerts;
- invalid timezone value;
- both `tz` and `TZ` are present with different values.
Diagnostic: compare the request against the route's query parameters in
[`docs/api.md`](api.md). Reproduce with `curl -i` to see the status and body.
Safe fix: remove unsupported parameters or correct values. Use `tz=Chicago`,
`tz=America/Chicago`, a supported US abbreviation, or an offset such as `TZ=-5`
on routes that accept timezone selection.
## Requests Return `406 unsupported_format`
Symptom: the response status is `406 Not Acceptable` and the error code is
`unsupported_format`.
Likely causes:
- `format` has a value other than `json`, `xml`, or `text`;
- the `Accept` header cannot be matched to a registered renderer;
- the configured default format is unsupported.
Diagnostic:
```sh
curl -i 'http://localhost:8080/observations?format=json'
curl -i -H 'Accept: application/json' 'http://localhost:8080/observations'
```
Safe fix: request `format=json`, `format=xml`, or `format=text`, or set
`server.default_format` to a supported value.
## Text Responses Fail or Do Not Render Expected Text
Symptom: `format=text` fails, returns an error, or does not contain expected
endpoint text.
Likely causes:
- `templates.base_dir` points to the wrong directory;
- templates were not copied into the runtime container or deployment directory;
- file permissions prevent reading templates;
- a customized template has invalid syntax.
Diagnostic:
```sh
find templates -maxdepth 1 -name '*.txt.tmpl' -print | sort
curl -i 'http://localhost:8080/forecast/hourly?format=text'
```
Safe fix: restore the repository's `templates/` directory or update
`templates.base_dir` to the mounted template path. In the Docker image, the
default template directory is `/weatherapi/templates`.
## Successful Response Has `data: null`
Symptom: the response is `200 OK`, but JSON contains `"data": null`.
Likely causes:
- weatherfeeder has not populated the relevant table yet;
- the relevant latest row does not exist after a database restore;
- the service is pointed at an empty or wrong database;
- current conditions have no observations in the implemented 30-minute window.
Diagnostic:
```sh
curl -s 'http://localhost:8080/observations?format=json'
curl -s 'http://localhost:8080/conditions/current?format=json'
```
Safe fix: verify weatherfeeder is running and writing to the same database that
`weatherapi` uses as its first configured database. For current conditions,
wait for recent observations or inspect weatherfeeder ingestion.
For convective outlooks, also verify that weatherfeeder has applied its
`weather.outlook.v2` table shape and is writing `outlook_runs`, `outlooks`, and
`outlook_discussions`.
## Forecast Day Routes Return Empty `periods`
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,
`/forecast/narrative/today`, or `/forecast/narrative/tomorrow` returns a
forecast object with an empty `periods` array.
Likely causes:
- no period `startTime` falls on that calendar day in the selected timezone;
- the request omitted `tz`, so UTC day boundaries were used;
- the stored forecast is stale.
Diagnostic:
```sh
curl -s 'http://localhost:8080/forecast/hourly/today?tz=America/Chicago'
curl -s 'http://localhost:8080/forecast/hourly?tz=America/Chicago'
```
Safe fix: provide the intended `tz` value and verify the unfiltered forecast
contains periods for the expected local date.
## Timezone Errors
Symptom: requests with `tz` or `TZ` return `400 invalid_parameter`.
Likely causes:
- the timezone is not an IANA location, supported US abbreviation, supported
alias, or valid UTC offset;
- an offset is outside `-14:00` through `+14:00`;
- both `tz` and `TZ` are present but do not match case-insensitively.
Diagnostic:
```sh
curl -i 'http://localhost:8080/forecast/hourly?tz=not-a-timezone'
curl -i 'http://localhost:8080/forecast/hourly?tz=CDT&TZ=EST'
```
Safe fix: use one timezone parameter. Known-good examples include
`tz=America/Chicago`, `tz=Chicago`, `tz=CDT`, and `TZ=-5`.
## Precision Errors
Symptom: requests with `precision` return `400 invalid_parameter`.
Likely causes:
- value is not an integer;
- value is less than `0` or greater than `2`;
- route does not accept `precision`.
Diagnostic:
```sh
curl -i 'http://localhost:8080/conditions/current?precision=3'
curl -i 'http://localhost:8080/alerts/active?precision=1'
```
Safe fix: use `precision=0`, `precision=1`, or `precision=2` only on
observations, current conditions, and forecast routes.
## Missing or Stale Weather Data
Symptom: responses are successful but older than expected, or endpoint families
are empty.
Likely causes:
- weatherfeeder is stopped or failing;
- weatherfeeder writes to a different database than `weatherapi` reads;
- a restore or deployment changed database credentials;
- upstream provider ingestion is delayed outside `weatherapi`.
Diagnostic: inspect weatherfeeder logs and query the configured Postgres
database directly for recent rows in the relevant weatherfeeder tables.
Safe fix: repair weatherfeeder ingestion or database routing. Restart
`weatherapi` only when config or database connectivity changed.
## Related Docs
- [`docs/api.md`](api.md): endpoint contract, query parameters, errors
- [`docs/config.md`](config.md): YAML fields and examples
- [`docs/operations.md`](operations.md): runtime and recovery boundaries
- [`docs/integrations/feedapi.md`](integrations/feedapi.md): config, rendering, and error-contract boundaries
- [`docs/integrations/weatherfeeder-postgres.md`](integrations/weatherfeeder-postgres.md): table and freshness assumptions

View File

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

View File

@@ -0,0 +1,20 @@
server:
listen_addr: ":8080"
default_format: json
read_timeout: 5s
write_timeout: 10s
idle_timeout: 120s
databases:
- name: weatherdb
driver: postgres
uri: postgres://postgres.example.internal:5432/weatherdb?sslmode=require
username: weatherapi
password: ${WEATHERAPI_DB_PASSWORD}
max_open_conns: 10
max_idle_conns: 5
conn_max_lifetime: 30m
conn_max_idle_time: 5m
templates:
base_dir: templates

52
examples/requests.http Normal file
View File

@@ -0,0 +1,52 @@
@baseUrl = http://localhost:8080
### Latest observation
GET {{baseUrl}}/observations?units=metric&precision=1
Accept: application/json
### Current conditions
GET {{baseUrl}}/conditions/current?units=us&precision=0
Accept: application/json
### Active alerts as text
GET {{baseUrl}}/alerts/active?format=text
### Convective outlook day 1 categorical risk
GET {{baseUrl}}/outlooks/convective?day=1&outlookType=categorical
Accept: application/json
### Active convective outlooks as text
GET {{baseUrl}}/outlooks/convective/active?format=text&tz=CDT
### Hourly forecast in US units
GET {{baseUrl}}/forecast/hourly?units=us&precision=1&tz=Chicago
Accept: application/json
### Hourly forecast for today
GET {{baseUrl}}/forecast/hourly/today?tz=CDT
Accept: application/json
### Narrative forecast for tomorrow as text
GET {{baseUrl}}/forecast/narrative/tomorrow?format=text&tz=America/Chicago
### Full forecast discussion
GET {{baseUrl}}/discussion?tz=CDT
Accept: application/json
### Forecast discussion key messages as text
GET {{baseUrl}}/discussion/key-messages?format=text
### Forecast discussion short term
GET {{baseUrl}}/discussion/short-term?tz=CDT
Accept: application/json
### Forecast discussion long term
GET {{baseUrl}}/discussion/long-term?TZ=-5
Accept: application/json
### Weather stories
GET {{baseUrl}}/weatherstories?tz=America/Chicago
Accept: application/json
### Latest weather story as XML
GET {{baseUrl}}/weatherstories/latest?format=xml

16
go.mod
View File

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

41
go.sum
View File

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

View File

@@ -1,189 +0,0 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/conditions"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/timeparse"
)
type ObservationService interface {
GetRecent(ctx context.Context, count int, unitSystem string) (observations.Response, error)
}
type ConditionsService interface {
GetCurrent(ctx context.Context, unitSystem string) (conditions.Response, error)
}
type ForecastService interface {
GetByTimestamp(ctx context.Context, ts time.Time, unitSystem string) (forecasts.Response, error)
}
type AlertService interface {
GetCurrent(ctx context.Context) (alerts.Response, error)
}
type Server struct {
obsSvc ObservationService
conditionsSvc ConditionsService
fcSvc ForecastService
alertsSvc AlertService
}
func NewServer(obsSvc ObservationService, conditionsSvc ConditionsService, fcSvc ForecastService, alertsSvc AlertService) *Server {
return &Server{obsSvc: obsSvc, conditionsSvc: conditionsSvc, fcSvc: fcSvc, alertsSvc: alertsSvc}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/observations", s.handleObservations)
mux.HandleFunc("/conditions/current", s.handleConditionsCurrent)
mux.HandleFunc("/forecast", s.handleForecast)
mux.HandleFunc("/alerts/current", s.handleAlertsCurrent)
return mux
}
func (s *Server) handleConditionsCurrent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
unitSystem, err := parseUnits(r, constants.UnitSystemUS)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
resp, err := s.conditionsSvc.GetCurrent(r.Context(), unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch current conditions")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleObservations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
unitSystem, err := parseUnits(r, constants.UnitSystemMetric)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
count, err := parseCount(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
resp, err := s.obsSvc.GetRecent(r.Context(), count, unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch observations")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleForecast(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
unitSystem, err := parseUnits(r, constants.UnitSystemUS)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
raw := r.URL.Query().Get("timestamp")
ts, err := timeparse.ParseTimestamp(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
resp, err := s.fcSvc.GetByTimestamp(r.Context(), ts, unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch forecast")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleAlertsCurrent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
resp, err := s.alertsSvc.GetCurrent(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch current alerts")
return
}
writeJSON(w, http.StatusOK, resp)
}
func parseCount(r *http.Request) (int, error) {
raw := strings.TrimSpace(r.URL.Query().Get("count"))
if raw == "" {
return constants.DefaultObservationCount, nil
}
count, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("count must be an integer between 1 and %d", constants.MaxObservationCount)
}
if count < 1 || count > constants.MaxObservationCount {
return 0, fmt.Errorf("count must be an integer between 1 and %d", constants.MaxObservationCount)
}
return count, nil
}
func parseUnits(r *http.Request, defaultSystem string) (string, error) {
raw := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("units")))
if raw == "" {
return defaultSystem, nil
}
switch raw {
case constants.UnitSystemUS, constants.UnitSystemMetric:
return raw, nil
default:
return "", fmt.Errorf("units must be one of: us, metric")
}
}
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Code: code, Message: message})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}

View File

@@ -1,493 +0,0 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/conditions"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
type fakeObservationRepo struct {
summary ports.ObservationCurrentConditionsMetric
summaryWindow time.Duration
observations []ports.ObservationRecordMetric
recentCount int
}
func (f *fakeObservationRepo) GetCurrentConditionsSummary(_ context.Context, window time.Duration) (ports.ObservationCurrentConditionsMetric, error) {
f.summaryWindow = window
return f.summary, nil
}
func (f *fakeObservationRepo) ListRecentObservations(_ context.Context, count int) ([]ports.ObservationRecordMetric, error) {
f.recentCount = count
return f.observations, nil
}
type fakeForecastRepo struct {
periods []ports.ForecastPeriodMetric
gotTS time.Time
gotLimit int
}
func (f *fakeForecastRepo) ListForecastPeriodsAt(_ context.Context, ts time.Time, limit int) ([]ports.ForecastPeriodMetric, error) {
f.gotTS = ts
f.gotLimit = limit
return f.periods, nil
}
type fakeAlertRepo struct{}
func (fakeAlertRepo) ListCurrentAlerts(context.Context) ([]ports.AlertRecord, error) {
return nil, nil
}
func newTestUnitRegistry(t *testing.T) *units.Registry {
t.Helper()
reg := units.NewRegistry()
if err := reg.Register(units.StaticFactory{UnitSystem: constants.UnitSystemUS, Converter: units.USConverter{}}); err != nil {
t.Fatalf("register us converter: %v", err)
}
if err := reg.Register(units.StaticFactory{UnitSystem: constants.UnitSystemMetric, Converter: units.MetricConverter{}}); err != nil {
t.Fatalf("register metric converter: %v", err)
}
return reg
}
func newTestHandler(t *testing.T, obsRepo *fakeObservationRepo, fcRepo *fakeForecastRepo) http.Handler {
t.Helper()
reg := newTestUnitRegistry(t)
return NewServer(
observations.NewService(obsRepo, reg),
conditions.NewService(obsRepo, reg, constants.ObservationWindow),
forecasts.NewService(fcRepo, reg, constants.ForecastQueryLimit),
alerts.NewService(fakeAlertRepo{}),
).Handler()
}
func TestLegacyObservationsCurrentPathRemoved(t *testing.T) {
server := newTestHandler(t, &fakeObservationRepo{}, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/observations/current", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for removed /observations/current, got %d", w.Code)
}
}
func TestConditionsCurrentDefaultsToUSUnits(t *testing.T) {
tempC := 20.0
appTempC := 15.0
dewpointC := 10.0
relHumidity := 81.0
windKmh := 10.0
windDir := 270.0
conditionCode := 63
isDay := true
obsRepo := &fakeObservationRepo{
summary: ports.ObservationCurrentConditionsMetric{
TemperatureC: &tempC,
ApparentTemperatureC: &appTempC,
DewpointC: &dewpointC,
RelativeHumidity: &relHumidity,
WindSpeedKmh: &windKmh,
WindDirectionDegrees: &windDir,
ConditionCode: &conditionCode,
IsDay: &isDay,
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conditions/current", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := payload["temperatureF"].(float64); got != 68.0 {
t.Fatalf("expected temperatureF=68.0, got %v", got)
}
if got := payload["apparentTemperatureF"].(float64); got != 59.0 {
t.Fatalf("expected apparentTemperatureF=59.0, got %v", got)
}
if got := payload["dewpointF"].(float64); got != 50.0 {
t.Fatalf("expected dewpointF=50.0, got %v", got)
}
if got := payload["windSpeedMph"].(float64); got != 6.2 {
t.Fatalf("expected windSpeedMph=6.2, got %v", got)
}
if got := payload["relativeHumidityPercent"].(float64); got != 81.0 {
t.Fatalf("expected relativeHumidityPercent=81.0, got %v", got)
}
if got := payload["windDirectionDegrees"].(float64); got != 270.0 {
t.Fatalf("expected windDirectionDegrees=270.0, got %v", got)
}
if got := payload["conditionText"].(string); got != "Rain" {
t.Fatalf("expected conditionText=Rain, got %v", got)
}
if got := payload["isDay"].(bool); !got {
t.Fatalf("expected isDay=true, got %v", got)
}
if _, exists := payload["conditionCode"]; exists {
t.Fatalf("did not expect conditionCode in conditions response")
}
if _, exists := payload["temperatureC"]; exists {
t.Fatalf("did not expect metric key temperatureC in default US response")
}
if obsRepo.summaryWindow != constants.ObservationWindow {
t.Fatalf("expected observation window %s, got %s", constants.ObservationWindow, obsRepo.summaryWindow)
}
}
func TestConditionsCurrentSupportsMetricUnits(t *testing.T) {
tempC := 20.0
windKmh := 10.0
conditionCode := 63
isDay := false
obsRepo := &fakeObservationRepo{
summary: ports.ObservationCurrentConditionsMetric{
TemperatureC: &tempC,
WindSpeedKmh: &windKmh,
ConditionCode: &conditionCode,
IsDay: &isDay,
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conditions/current?units=metric", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := payload["temperatureC"].(float64); got != 20.0 {
t.Fatalf("expected temperatureC=20.0, got %v", got)
}
if got := payload["windSpeedKmh"].(float64); got != 10.0 {
t.Fatalf("expected windSpeedKmh=10.0, got %v", got)
}
if got := payload["conditionText"].(string); got != "Rain" {
t.Fatalf("expected conditionText=Rain, got %v", got)
}
if got := payload["isDay"].(bool); got {
t.Fatalf("expected isDay=false, got %v", got)
}
if _, exists := payload["conditionCode"]; exists {
t.Fatalf("did not expect conditionCode in conditions response")
}
if _, exists := payload["temperatureF"]; exists {
t.Fatalf("did not expect US key temperatureF in metric response")
}
}
func TestConditionsCurrentOmitsConditionFieldsWhenNoObservations(t *testing.T) {
obsRepo := &fakeObservationRepo{
summary: ports.ObservationCurrentConditionsMetric{},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conditions/current", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if _, exists := payload["conditionCode"]; exists {
t.Fatalf("expected conditionCode omitted when no observations")
}
if _, exists := payload["conditionText"]; exists {
t.Fatalf("expected conditionText omitted when no observations")
}
if _, exists := payload["isDay"]; exists {
t.Fatalf("expected isDay omitted when no observations")
}
}
func TestConditionsCurrentConditionTextUsesNilDayFallback(t *testing.T) {
conditionCode := 0
obsRepo := &fakeObservationRepo{
summary: ports.ObservationCurrentConditionsMetric{
ConditionCode: &conditionCode,
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conditions/current", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := payload["conditionText"].(string); got != "Sunny" {
t.Fatalf("expected conditionText=Sunny, got %v", got)
}
if _, exists := payload["isDay"]; exists {
t.Fatalf("expected isDay omitted when summary isDay is nil")
}
}
func TestObservationsDefaultsToMetricAndDefaultCount(t *testing.T) {
tempC := 10.0
windKmh := 16.0
stationID := "KSTL"
stationName := "St Louis"
textDescription := "Cloudy"
obsRepo := &fakeObservationRepo{
observations: []ports.ObservationRecordMetric{
{
EventID: "evt-1",
StationID: &stationID,
StationName: &stationName,
Timestamp: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
ConditionCode: 3,
TextDescription: &textDescription,
TemperatureC: &tempC,
WindSpeedKmh: &windKmh,
PresentWeather: []ports.ObservationPresentWeatherMetric{{Raw: map[string]any{"wx": "rain"}}},
},
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/observations", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if obsRepo.recentCount != constants.DefaultObservationCount {
t.Fatalf("expected default count %d, got %d", constants.DefaultObservationCount, obsRepo.recentCount)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
observationsList := payload["observations"].([]any)
first := observationsList[0].(map[string]any)
if got := first["temperatureC"].(float64); got != 10.0 {
t.Fatalf("expected temperatureC=10.0, got %v", got)
}
if got := first["windSpeedKmh"].(float64); got != 16.0 {
t.Fatalf("expected windSpeedKmh=16.0, got %v", got)
}
if _, exists := first["temperatureF"]; exists {
t.Fatalf("did not expect US key temperatureF in metric response")
}
if got := first["conditionCode"].(float64); got != 3 {
t.Fatalf("expected conditionCode=3, got %v", got)
}
pw := first["presentWeather"].([]any)
raw := pw[0].(map[string]any)["raw"].(map[string]any)
if got := raw["wx"]; got != "rain" {
t.Fatalf("expected presentWeather raw payload preserved, got %v", got)
}
}
func TestObservationsSupportsUSUnitsAndCountOverride(t *testing.T) {
tempC := 10.0
windKmh := 16.09344
obsRepo := &fakeObservationRepo{
observations: []ports.ObservationRecordMetric{
{
EventID: "evt-1",
Timestamp: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
ConditionCode: 1,
TemperatureC: &tempC,
WindSpeedKmh: &windKmh,
},
},
}
server := newTestHandler(t, obsRepo, &fakeForecastRepo{})
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/observations?count=2&units=us", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if obsRepo.recentCount != 2 {
t.Fatalf("expected count override 2, got %d", obsRepo.recentCount)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
first := payload["observations"].([]any)[0].(map[string]any)
if got := first["temperatureF"].(float64); got != 50.0 {
t.Fatalf("expected temperatureF=50.0, got %v", got)
}
if got := first["windSpeedMph"].(float64); got != 10.0 {
t.Fatalf("expected windSpeedMph=10.0, got %v", got)
}
if _, exists := first["temperatureC"]; exists {
t.Fatalf("did not expect metric key temperatureC in US response")
}
}
func TestObservationsCountValidation(t *testing.T) {
server := newTestHandler(t, &fakeObservationRepo{}, &fakeForecastRepo{})
cases := []string{
"/observations?count=0",
"/observations?count=-1",
"/observations?count=abc",
"/observations?count=101",
}
for _, path := range cases {
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for %s, got %d", path, w.Code)
}
}
}
func TestInvalidUnitsReturn400(t *testing.T) {
server := newTestHandler(t, &fakeObservationRepo{}, &fakeForecastRepo{})
cases := []string{
"/conditions/current?units=bad",
"/observations?units=bad",
"/forecast?timestamp=2026-03-17T12:30:00Z&units=bad",
}
for _, path := range cases {
w := httptest.NewRecorder()
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for %s, got %d", path, w.Code)
}
}
}
func TestForecastInvalidTimestampReturns400(t *testing.T) {
server := newTestHandler(t, &fakeObservationRepo{}, &fakeForecastRepo{})
req := httptest.NewRequest(http.MethodGet, "/forecast?timestamp=bad-time", nil)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["code"] != "invalid_request" {
t.Fatalf("expected invalid_request code, got %v", payload["code"])
}
}
func TestForecastUnitSelection(t *testing.T) {
tempC := 0.0
tempMinC := -1.0
tempMaxC := 1.0
appTempC := -2.0
windKmh := 10.0
gustKmh := 16.09344
name := "Now"
fcRepo := &fakeForecastRepo{
periods: []ports.ForecastPeriodMetric{
{
PeriodIndex: 1,
StartTime: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 3, 17, 13, 0, 0, 0, time.UTC),
Name: &name,
ConditionCode: 1,
TemperatureC: &tempC,
TemperatureCMin: &tempMinC,
TemperatureCMax: &tempMaxC,
ApparentTemperatureC: &appTempC,
WindSpeedKmh: &windKmh,
WindGustKmh: &gustKmh,
},
},
}
server := newTestHandler(t, &fakeObservationRepo{}, fcRepo)
wUS := httptest.NewRecorder()
server.ServeHTTP(wUS, httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z", nil))
if wUS.Code != http.StatusOK {
t.Fatalf("expected US forecast 200, got %d", wUS.Code)
}
var usPayload map[string]any
if err := json.Unmarshal(wUS.Body.Bytes(), &usPayload); err != nil {
t.Fatalf("decode us forecast: %v", err)
}
usPeriod := usPayload["periods"].([]any)[0].(map[string]any)
if got := usPeriod["temperatureF"].(float64); got != 32.0 {
t.Fatalf("expected temperatureF=32.0, got %v", got)
}
if got := usPeriod["windSpeedMph"].(float64); got != 6.2 {
t.Fatalf("expected windSpeedMph=6.2, got %v", got)
}
if _, exists := usPeriod["temperatureC"]; exists {
t.Fatalf("did not expect metric key temperatureC in US response")
}
wMetric := httptest.NewRecorder()
server.ServeHTTP(wMetric, httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z&units=metric", nil))
if wMetric.Code != http.StatusOK {
t.Fatalf("expected metric forecast 200, got %d", wMetric.Code)
}
var metricPayload map[string]any
if err := json.Unmarshal(wMetric.Body.Bytes(), &metricPayload); err != nil {
t.Fatalf("decode metric forecast: %v", err)
}
metricPeriod := metricPayload["periods"].([]any)[0].(map[string]any)
if got := metricPeriod["temperatureC"].(float64); got != 0.0 {
t.Fatalf("expected temperatureC=0.0, got %v", got)
}
if got := metricPeriod["windSpeedKmh"].(float64); got != 10.0 {
t.Fatalf("expected windSpeedKmh=10.0, got %v", got)
}
if _, exists := metricPeriod["temperatureF"]; exists {
t.Fatalf("did not expect US key temperatureF in metric response")
}
if fcRepo.gotLimit != constants.ForecastQueryLimit {
t.Fatalf("expected forecast query limit %d, got %d", constants.ForecastQueryLimit, fcRepo.gotLimit)
}
if fcRepo.gotTS.IsZero() {
t.Fatalf("expected forecast timestamp passed to repo")
}
}

View File

@@ -0,0 +1,31 @@
// alerts_endpoint.go defines the /alerts/active endpoint behavior.
// Layer: adapters/inbound/httpapi alerts route.
package httpapi
import (
"context"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
var alertNow = time.Now
func alertsDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/alerts/active",
bindQuery,
func(ctx context.Context, req queryRequest) (any, error) {
run, err := svc.LatestActiveAlertRun(ctx, alertNow().UTC())
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.AlertsPayload(run, req.Units)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("alerts_active.txt.tmpl"),
)
}

View File

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

View File

@@ -0,0 +1,72 @@
// discussion_endpoint.go defines the /discussion endpoint behavior.
// Layer: adapters/inbound/httpapi discussion route.
package httpapi
import (
"context"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func discussionDefinitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{
discussionDefinition(
"/discussion",
"discussion.txt.tmpl",
func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any {
return presenter.DiscussionPayload(run, units, tz)
},
svc,
),
discussionDefinition(
"/discussion/key-messages",
"discussion_key_messages.txt.tmpl",
func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any {
return presenter.DiscussionKeyMessagesOnlyPayload(run, units, tz)
},
svc,
),
discussionDefinition(
"/discussion/short-term",
"discussion_short_term.txt.tmpl",
func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any {
return presenter.DiscussionShortTermOnlyPayload(run, units, tz)
},
svc,
),
discussionDefinition(
"/discussion/long-term",
"discussion_long_term.txt.tmpl",
func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any {
return presenter.DiscussionLongTermOnlyPayload(run, units, tz)
},
svc,
),
}
}
func discussionDefinition(
path string,
templateName string,
present func(*model.WeatherForecastDiscussion, presenter.Units, *time.Location) any,
svc Service,
) endpoint.Definition {
return endpoint.GET(
path,
bindTimezoneQuery,
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
run, err := svc.LatestForecastDiscussion(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: present(run, req.Units, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate(templateName),
)
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
// outlooks_endpoint.go defines convective outlook endpoint behavior.
// Layer: adapters/inbound/httpapi outlook routes.
package httpapi
import (
"context"
"net/http"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
type outlookFilterMode int
const (
outlookFilterUser outlookFilterMode = iota
outlookFilterActive
)
var outlookNow = time.Now
func outlookDefinitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{
outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc),
outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc),
}
}
func outlookDefinition(
path string,
mode outlookFilterMode,
binder func(*http.Request) (outlookQueryRequest, error),
svc Service,
) endpoint.Definition {
return endpoint.GET(
path,
binder,
func(ctx context.Context, req outlookQueryRequest) (any, error) {
filter := req.Filter
if mode == outlookFilterActive {
activeAt := outlookNow().UTC()
filter.ActiveAt = &activeAt
}
run, err := svc.LatestConvectiveOutlook(ctx, filter)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.OutlookRunPayload(run, req.Units, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("outlooks_convective.txt.tmpl"),
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
// discussion.go presents forecast discussion payloads.
// Layer: adapters/inbound/httpapi/presenter discussion payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type DiscussionKeyMessagesPayload struct {
OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"`
Product model.ForecastDiscussionProduct `json:"product" xml:"product"`
IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"`
KeyMessages []string `json:"keyMessages" xml:"keyMessages"`
}
type DiscussionShortTermPayload struct {
OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"`
Product model.ForecastDiscussionProduct `json:"product" xml:"product"`
IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"`
ShortTerm *model.WeatherForecastDiscussionSection `json:"shortTerm,omitempty" xml:"shortTerm,omitempty"`
}
type DiscussionLongTermPayload struct {
OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"`
Product model.ForecastDiscussionProduct `json:"product" xml:"product"`
IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"`
LongTerm *model.WeatherForecastDiscussionSection `json:"longTerm,omitempty" xml:"longTerm,omitempty"`
}
func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
out := model.WeatherForecastDiscussion{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
KeyMessages: append([]string(nil), run.KeyMessages...),
ShortTerm: copyDiscussionSection(run.ShortTerm, tz),
LongTerm: copyDiscussionSection(run.LongTerm, tz),
}
return &out
}
func DiscussionKeyMessagesOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
return DiscussionKeyMessagesPayload{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
KeyMessages: append([]string{}, run.KeyMessages...),
}
}
func DiscussionShortTermOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
return DiscussionShortTermPayload{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
ShortTerm: copyDiscussionSection(run.ShortTerm, tz),
}
}
func DiscussionLongTermOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
return DiscussionLongTermPayload{
OfficeID: run.OfficeID,
OfficeName: run.OfficeName,
Product: run.Product,
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
LongTerm: copyDiscussionSection(run.LongTerm, tz),
}
}
func copyDiscussionSection(in *model.WeatherForecastDiscussionSection, tz *time.Location) *model.WeatherForecastDiscussionSection {
if in == nil {
return nil
}
return &model.WeatherForecastDiscussionSection{
Qualifier: in.Qualifier,
IssuedAt: inLocationTimePtr(in.IssuedAt, tz),
Text: in.Text,
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
// outlook.go presents convective outlook payloads.
// Layer: adapters/inbound/httpapi/presenter outlook payload mapping.
package presenter
import (
"encoding/json"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
out := model.WeatherOutlookRun{
LocationID: run.LocationID,
LocationName: run.LocationName,
Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude),
AsOf: inLocationTime(run.AsOf, tz),
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
Discussions: make([]model.WeatherOutlookDiscussion, 0, len(run.Discussions)),
}
for _, outlook := range run.Outlooks {
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
}
for _, discussion := range run.Discussions {
out.Discussions = append(out.Discussions, copyOutlookDiscussion(discussion, tz))
}
return &out
}
func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherOutlook {
out := model.WeatherOutlook{
ID: outlook.ID,
Provider: outlook.Provider,
Product: outlook.Product,
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
SeverityRank: copyIntPtr(outlook.SeverityRank),
ValidFrom: inLocationTime(outlook.ValidFrom, tz),
ValidTo: inLocationTime(outlook.ValidTo, tz),
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
Forecaster: outlook.Forecaster,
SourceURL: outlook.SourceURL,
ImageURL: outlook.ImageURL,
ContainsLocation: outlook.ContainsLocation,
}
if outlook.Geometry != nil {
out.Geometry = json.RawMessage(append([]byte(nil), outlook.Geometry...))
}
return out
}
func copyOutlookDiscussion(discussion model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion {
return model.WeatherOutlookDiscussion{
Day: discussion.Day,
Headline: discussion.Headline,
Summary: discussion.Summary,
Discussion: discussion.Discussion,
UpdatedAt: inLocationTimePtr(discussion.UpdatedAt, tz),
}
}
func copyIntPtr(v *int) *int {
if v == nil {
return nil
}
out := *v
return &out
}

View File

@@ -0,0 +1,639 @@
// payload_test.go validates presenter conversion and payload shaping behavior.
// Layer: adapters/inbound/httpapi/presenter unit and schema tests.
package presenter
import (
"encoding/json"
"math"
"testing"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func TestObservationPayloadUS(t *testing.T) {
obs := &model.WeatherObservation{
StationID: "KSTL",
Timestamp: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
ConditionCode: 2,
TemperatureC: float64Ptr(20),
DewpointC: float64Ptr(10),
WindSpeedKmh: float64Ptr(100),
WindGustKmh: float64Ptr(80),
BarometricPressurePa: float64Ptr(101325),
VisibilityMeters: float64Ptr(1609.344),
ApparentTemperatureC: float64Ptr(25),
RelativeHumidityPercent: float64Ptr(50),
}
payload := ObservationPayload(obs, UnitsUS, 2)
converted, ok := payload.(WeatherObservationUS)
if !ok {
t.Fatalf("expected WeatherObservationUS payload, got %T", payload)
}
assertApprox(t, converted.TemperatureF, 68.0, 0.0001)
assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001)
assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001)
assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001)
if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil {
t.Fatalf("expected converted Fahrenheit fields to be populated")
}
}
func TestForecastPayloadUS(t *testing.T) {
issuedAt := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(1 * time.Hour)
run := &model.WeatherForecastRun{
LocationID: "stl",
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Product: model.ForecastProductHourly,
ElevationMeters: float64Ptr(1000),
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(1 * time.Hour),
ConditionCode: wmoCodePtr(63),
TemperatureC: float64Ptr(0),
TemperatureCMin: float64Ptr(-5),
TemperatureCMax: float64Ptr(5),
WindSpeedKmh: float64Ptr(64.37376),
PrecipitationAmountMm: float64Ptr(25.4),
SnowfallDepthMM: float64Ptr(50.8),
}},
}
payload := ForecastPayload(run, UnitsUS, 2, nil)
converted, ok := payload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
}
assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001)
if len(converted.Periods) != 1 {
t.Fatalf("expected 1 period, got %d", len(converted.Periods))
}
period := converted.Periods[0]
assertApprox(t, period.TemperatureF, 32.0, 0.0001)
assertApprox(t, period.TemperatureFMin, 23.0, 0.0001)
assertApprox(t, period.TemperatureFMax, 41.0, 0.0001)
assertApprox(t, period.WindSpeedMph, 40.0, 0.0001)
assertApprox(t, period.PrecipitationAmountIn, 1.0, 0.0001)
assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
}
func TestForecastPayloadOmitsLegacyDescriptionFields(t *testing.T) {
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
Periods: []model.WeatherForecastPeriod{{
StartTime: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 3, 20, 13, 0, 0, 0, time.UTC),
ConditionCode: wmoCodePtr(model.WMOUnknown),
TextDescription: "Cloudy",
}},
}
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsMetric, 0, nil))
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsUS, 0, nil))
}
func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt.Add(1 * time.Hour),
EndTime: issuedAt.Add(2 * time.Hour),
ConditionCode: wmoCodePtr(model.WMOUnknown),
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, loc)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertOffsetSeconds(t, metric.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60)
if !metric.IssuedAt.UTC().Equal(issuedAt) {
t.Fatalf("expected metric issuedAt to preserve instant")
}
usPayload := ForecastPayload(run, UnitsUS, 0, loc)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertOffsetSeconds(t, us.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60)
// Source model remains untouched.
assertOffsetSeconds(t, run.IssuedAt, 0)
assertOffsetSeconds(t, *run.UpdatedAt, 0)
}
func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(time.Hour)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(time.Hour),
ConditionCode: wmoCodePtr(model.WMOUnknown),
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
if metric == run {
t.Fatalf("expected metric payload to be copied")
}
if metric.UpdatedAt == run.UpdatedAt {
t.Fatalf("expected updatedAt pointer to be copied")
}
if !metric.IssuedAt.Equal(run.IssuedAt) {
t.Fatalf("expected issuedAt to remain unchanged without timezone flag")
}
assertOffsetSeconds(t, metric.IssuedAt, 0)
assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
}
func TestDiscussionPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
shortIssuedAt := issuedAt.Add(-15 * time.Minute)
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
KeyMessages: []string{"msg one"},
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
}
payload := DiscussionPayload(run, UnitsMetric, loc)
discussion, ok := payload.(*model.WeatherForecastDiscussion)
if !ok {
t.Fatalf("expected *model.WeatherForecastDiscussion payload, got %T", payload)
}
if discussion == run {
t.Fatalf("expected discussion payload to be copied")
}
if discussion.ShortTerm == run.ShortTerm {
t.Fatalf("expected shortTerm pointer to be copied")
}
assertOffsetSeconds(t, discussion.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *discussion.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, *discussion.ShortTerm.IssuedAt, -5*60*60)
if discussion.KeyMessages[0] != "msg one" {
t.Fatalf("expected key message preserved, got %#v", discussion.KeyMessages)
}
assertOffsetSeconds(t, run.IssuedAt, 0)
}
func TestDiscussionFocusedPayloads(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
shortIssuedAt := issuedAt.Add(-15 * time.Minute)
longIssuedAt := issuedAt.Add(15 * time.Minute)
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
OfficeName: "National Weather Service Saint Louis MO",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
KeyMessages: nil,
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
LongTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"},
}
keyMessagesPayload := DiscussionKeyMessagesOnlyPayload(run, UnitsMetric, loc)
keyMessages, ok := keyMessagesPayload.(DiscussionKeyMessagesPayload)
if !ok {
t.Fatalf("expected DiscussionKeyMessagesPayload, got %T", keyMessagesPayload)
}
if keyMessages.KeyMessages == nil {
t.Fatalf("expected keyMessages slice to be non-nil")
}
if len(keyMessages.KeyMessages) != 0 {
t.Fatalf("expected empty keyMessages slice, got %#v", keyMessages.KeyMessages)
}
assertOffsetSeconds(t, keyMessages.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *keyMessages.UpdatedAt, -5*60*60)
shortPayload := DiscussionShortTermOnlyPayload(run, UnitsMetric, loc)
shortTerm, ok := shortPayload.(DiscussionShortTermPayload)
if !ok {
t.Fatalf("expected DiscussionShortTermPayload, got %T", shortPayload)
}
if shortTerm.ShortTerm == nil {
t.Fatalf("expected shortTerm section")
}
if shortTerm.ShortTerm == run.ShortTerm {
t.Fatalf("expected shortTerm section copy")
}
assertOffsetSeconds(t, *shortTerm.ShortTerm.IssuedAt, -5*60*60)
longPayload := DiscussionLongTermOnlyPayload(run, UnitsMetric, loc)
longTerm, ok := longPayload.(DiscussionLongTermPayload)
if !ok {
t.Fatalf("expected DiscussionLongTermPayload, got %T", longPayload)
}
if longTerm.LongTerm == nil {
t.Fatalf("expected longTerm section")
}
if longTerm.LongTerm == run.LongTerm {
t.Fatalf("expected longTerm section copy")
}
assertOffsetSeconds(t, *longTerm.LongTerm.IssuedAt, -5*60*60)
}
func TestDiscussionFocusedPayloadsPreserveNilSections(t *testing.T) {
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC),
}
shortPayload := DiscussionShortTermOnlyPayload(run, UnitsMetric, nil)
shortTerm, ok := shortPayload.(DiscussionShortTermPayload)
if !ok {
t.Fatalf("expected DiscussionShortTermPayload, got %T", shortPayload)
}
if shortTerm.ShortTerm != nil {
t.Fatalf("expected nil shortTerm, got %+v", shortTerm.ShortTerm)
}
longPayload := DiscussionLongTermOnlyPayload(run, UnitsMetric, nil)
longTerm, ok := longPayload.(DiscussionLongTermPayload)
if !ok {
t.Fatalf("expected DiscussionLongTermPayload, got %T", longPayload)
}
if longTerm.LongTerm != nil {
t.Fatalf("expected nil longTerm, got %+v", longTerm.LongTerm)
}
}
func TestMetricCopyAndNilHandling(t *testing.T) {
obs := &model.WeatherObservation{
TemperatureC: float64Ptr(20.6),
}
metric := ObservationPayload(obs, UnitsMetric, 0)
metricObs, ok := metric.(*model.WeatherObservation)
if !ok {
t.Fatalf("expected metric payload to remain model type, got %T", metric)
}
if metricObs == obs {
t.Fatalf("expected metric payload to be copied")
}
assertApprox(t, metricObs.TemperatureC, 21, 0.0001)
assertApprox(t, obs.TemperatureC, 20.6, 0.0001)
if metricObs.TemperatureC == obs.TemperatureC {
t.Fatalf("expected temperature pointer copy, got same pointer")
}
if ObservationPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil observation input to return nil payload")
}
if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
t.Fatalf("expected nil forecast input to return nil payload")
}
if AlertsPayload(nil, UnitsUS) != nil {
t.Fatalf("expected nil alerts input to return nil payload")
}
if DiscussionPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil discussion input to return nil payload")
}
if DiscussionKeyMessagesOnlyPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil key messages discussion input to return nil payload")
}
if DiscussionShortTermOnlyPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil short term discussion input to return nil payload")
}
if DiscussionLongTermOnlyPayload(nil, UnitsUS, nil) != nil {
t.Fatalf("expected nil long term discussion input to return nil payload")
}
if CurrentConditionsPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil current conditions input to return nil payload")
}
}
func TestAlertsPayloadPassThrough(t *testing.T) {
run := &model.WeatherAlertRun{
LocationID: "stl",
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
Alerts: []model.WeatherAlert{{
ID: "alert-1",
Headline: "Storm warning",
}},
}
payload := AlertsPayload(run, UnitsUS)
if payload != run {
t.Fatalf("expected alerts payload to pass through input run")
}
}
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
conditions := &app.CurrentConditions{
TemperatureC: float64Ptr(20),
ApparentTemperatureC: float64Ptr(18),
DewpointC: float64Ptr(10),
RelativeHumidityPercent: float64Ptr(55),
WindSpeedKmh: float64Ptr(100),
WindDirectionDegrees: float64Ptr(225),
ConditionCode: 0,
IsDay: boolPtr(true),
}
metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2)
metric, ok := metricPayload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload)
}
assertApprox(t, metric.TemperatureC, 20, 0.0001)
assertApprox(t, metric.WindSpeedKmh, 100, 0.0001)
if metric.TemperatureF != nil || metric.WindSpeedMph != nil {
t.Fatalf("expected US fields omitted for metric payload")
}
if metric.ConditionText != "Sunny" {
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
}
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
us, ok := usPayload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload)
}
assertApprox(t, us.TemperatureF, 68, 0.0001)
assertApprox(t, us.WindSpeedMph, 62.14, 0.0001)
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
t.Fatalf("expected metric fields omitted for US payload")
}
}
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
night := false
payload := CurrentConditionsPayload(&app.CurrentConditions{
ConditionCode: 0,
IsDay: &night,
}, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
}
if metric.ConditionText != "Clear" {
t.Fatalf("expected condition text Clear, got %q", metric.ConditionText)
}
}
func TestCurrentConditionsPayloadRoundsHalfAwayFromZero(t *testing.T) {
payload := CurrentConditionsPayload(&app.CurrentConditions{
TemperatureC: float64Ptr(-1.5),
}, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
}
assertApprox(t, metric.TemperatureC, -2, 0.0001)
}
func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
run := &model.WeatherForecastRun{
Latitude: float64Ptr(38.627123),
Longitude: float64Ptr(-90.199456),
ElevationMeters: float64Ptr(10.499),
Product: model.ForecastProductHourly,
IssuedAt: time.Now().UTC(),
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertApprox(t, metric.Latitude, 38.627123, 0.000001)
assertApprox(t, metric.Longitude, -90.199456, 0.000001)
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
usPayload := ForecastPayload(run, UnitsUS, 0, nil)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertApprox(t, us.Latitude, 38.627123, 0.000001)
assertApprox(t, us.Longitude, -90.199456, 0.000001)
}
func TestOutlookRunPayloadNilInputReturnsNil(t *testing.T) {
if OutlookRunPayload(nil, UnitsUS, time.UTC) != nil {
t.Fatalf("expected nil outlook run input to return nil payload")
}
}
func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
issuedAt := asOf.Add(-1 * time.Hour)
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
severityRank := 5
latitude := 38.627123
longitude := -90.199456
geometry := json.RawMessage(`{"type":"Point","coordinates":[-90.2,38.6]}`)
run := &model.WeatherOutlookRun{
LocationID: "stl",
LocationName: "St. Louis",
Latitude: &latitude,
Longitude: &longitude,
AsOf: asOf,
IssuedAt: &issuedAt,
Outlooks: []model.WeatherOutlook{{
ID: "cat-1",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
SeverityRank: &severityRank,
ValidFrom: asOf,
ValidTo: asOf.Add(6 * time.Hour),
IssuedAt: issuedAt,
ExpiresAt: asOf.Add(6 * time.Hour),
Forecaster: "DIAL",
SourceURL: "https://example.test/source",
ImageURL: "https://example.test/image.png",
ContainsLocation: true,
Geometry: geometry,
}},
Discussions: []model.WeatherOutlookDiscussion{{
Day: 1,
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
UpdatedAt: &discussionUpdatedAt,
}},
}
payload := OutlookRunPayload(run, UnitsUS, loc)
out, ok := payload.(*model.WeatherOutlookRun)
if !ok {
t.Fatalf("expected *model.WeatherOutlookRun payload, got %T", payload)
}
if out == run {
t.Fatalf("expected outlook run payload to be copied")
}
if out.Latitude == run.Latitude || out.Longitude == run.Longitude || out.IssuedAt == run.IssuedAt {
t.Fatalf("expected run pointers to be copied")
}
if len(out.Outlooks) != 1 {
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
}
if len(out.Discussions) != 1 {
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
}
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
t.Fatalf("expected severity rank pointer to be copied")
}
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
t.Fatalf("expected discussion updatedAt pointer to be copied")
}
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
t.Fatalf("expected geometry bytes to be copied")
}
assertOffsetSeconds(t, out.AsOf, -5*60*60)
assertOffsetSeconds(t, *out.IssuedAt, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].ValidFrom, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -5*60*60)
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
t.Fatalf("expected timezone conversion to preserve instants")
}
if *out.Latitude != latitude || *out.Longitude != longitude {
t.Fatalf("expected latitude/longitude preserved, got %v %v", out.Latitude, out.Longitude)
}
if out.Outlooks[0].SeverityRank == nil || *out.Outlooks[0].SeverityRank != severityRank {
t.Fatalf("expected severity rank preserved, got %v", out.Outlooks[0].SeverityRank)
}
if string(out.Outlooks[0].Geometry) != string(geometry) {
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
}
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
out.Discussions[0].Discussion != "Discussion text." {
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
}
*out.Latitude = 99
*out.Longitude = -99
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
*out.Outlooks[0].SeverityRank = 99
*out.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
out.Outlooks[0].Geometry[0] = '['
out.Discussions[0].Headline = "changed"
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
t.Fatalf("expected source run pointers not to mutate")
}
if *run.Outlooks[0].SeverityRank != severityRank {
t.Fatalf("expected source severity rank not to mutate")
}
if string(run.Outlooks[0].Geometry) != string(geometry) {
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
}
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
}
if run.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
}
assertOffsetSeconds(t, run.AsOf, 0)
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
}
func float64Ptr(v float64) *float64 {
return &v
}
func boolPtr(v bool) *bool {
return &v
}
func wmoCodePtr(v model.WMOCode) *model.WMOCode {
out := v
return &out
}
func assertApprox(t *testing.T, got *float64, want, eps float64) {
t.Helper()
if got == nil {
t.Fatalf("expected value near %f, got nil", want)
}
if math.Abs(*got-want) > eps {
t.Fatalf("expected %f +/- %f, got %f", want, eps, *got)
}
}
func assertOffsetSeconds(t *testing.T, ts time.Time, want int) {
t.Helper()
_, got := ts.Zone()
if got != want {
t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339))
}
}
func assertForecastPayloadHasNoLegacyDescriptionFields(t *testing.T, payload any) {
t.Helper()
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal(payload) error = %v", err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("json.Unmarshal(payload) error = %v", err)
}
periodsRaw, ok := root["periods"].([]any)
if !ok || len(periodsRaw) == 0 {
t.Fatalf("expected non-empty periods in payload: %#v", root["periods"])
}
period, ok := periodsRaw[0].(map[string]any)
if !ok {
t.Fatalf("expected first period map, got %#v", periodsRaw[0])
}
for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} {
if _, exists := period[key]; exists {
t.Fatalf("unexpected legacy field %q in payload period: %#v", key, period)
}
}
if period["textDescription"] != "Cloudy" {
t.Fatalf("expected textDescription Cloudy, got %#v", period["textDescription"])
}
}

View File

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

View File

@@ -0,0 +1,48 @@
// weatherstories.go presents weather story payloads.
// Layer: adapters/inbound/httpapi/presenter weather stories payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func WeatherStoryRunPayload(run *model.WeatherStoryRun, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
out := model.WeatherStoryRun{
OfficeID: run.OfficeID,
AsOf: inLocationTime(run.AsOf, tz),
Stories: make([]model.WeatherStory, 0, len(run.Stories)),
}
for _, story := range run.Stories {
out.Stories = append(out.Stories, copyWeatherStory(story, tz))
}
return &out
}
func WeatherStoryPayload(story *model.WeatherStory, _ Units, tz *time.Location) any {
if story == nil {
return nil
}
out := copyWeatherStory(*story, tz)
return &out
}
func copyWeatherStory(story model.WeatherStory, tz *time.Location) model.WeatherStory {
return model.WeatherStory{
OfficeID: story.OfficeID,
StartTime: inLocationTime(story.StartTime, tz),
EndTime: inLocationTime(story.EndTime, tz),
UpdatedAt: inLocationTime(story.UpdatedAt, tz),
Title: story.Title,
Description: story.Description,
AltText: story.AltText,
Priority: story.Priority,
Order: story.Order,
DownloadURL: story.DownloadURL,
}
}

View File

@@ -0,0 +1,209 @@
// query_bind.go binds endpoint query parameters into typed request config.
// Layer: adapters/inbound/httpapi request binding.
package httpapi
import (
"net/http"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedapi/bind"
apierrors "gitea.maximumdirect.net/ejr/feedapi/errors"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
)
type queryRequest struct {
Units presenter.Units
}
type precisionQueryRequest struct {
Units presenter.Units
Precision int
Timezone *time.Location
}
type timezoneQueryRequest struct {
Units presenter.Units
Timezone *time.Location
}
type outlookQueryRequest struct {
Units presenter.Units
Timezone *time.Location
Filter app.OutlookFilter
}
func bindQuery(r *http.Request) (queryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
})
if err != nil {
return queryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
return queryRequest{Units: units}, nil
}
func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
return bindPrecisionQueryInternal(r, false)
}
func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
return bindPrecisionQueryInternal(r, true)
}
func bindTimezoneQuery(r *http.Request) (timezoneQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, "tz", "TZ")
if err != nil {
return timezoneQueryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
tz, err := parseTimezoneQuery(r)
if err != nil {
return timezoneQueryRequest{}, err
}
return timezoneQueryRequest{
Units: units,
Timezone: tz,
}, nil
}
func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
normalizeCommonQueryValue(r, "precision")
allowedExtra := []string{"precision"}
if allowTimezone {
allowedExtra = append(allowedExtra, "tz", "TZ")
}
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, allowedExtra...)
if err != nil {
return precisionQueryRequest{}, err
}
precision, err := bind.OptionalInt(r, "precision", 0)
if err != nil {
return precisionQueryRequest{}, err
}
if err := bind.MinInt(precision, 0, "precision"); err != nil {
return precisionQueryRequest{}, err
}
if err := bind.MaxInt(precision, 2, "precision"); err != nil {
return precisionQueryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
var tz *time.Location
if allowTimezone {
tz, err = parseTimezoneQuery(r)
if err != nil {
return precisionQueryRequest{}, err
}
}
return precisionQueryRequest{
Units: units,
Precision: precision,
Timezone: tz,
}, nil
}
func bindOutlookQuery(r *http.Request) (outlookQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
normalizeCommonQueryValue(r, "outlookType")
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, "tz", "TZ", "day", "outlookType")
if err != nil {
return outlookQueryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
tz, err := parseTimezoneQuery(r)
if err != nil {
return outlookQueryRequest{}, err
}
filter, err := bindOutlookFilter(r)
if err != nil {
return outlookQueryRequest{}, err
}
return outlookQueryRequest{
Units: units,
Timezone: tz,
Filter: filter,
}, nil
}
func bindOutlookFilter(r *http.Request) (app.OutlookFilter, error) {
var filter app.OutlookFilter
if strings.TrimSpace(r.URL.Query().Get("day")) != "" {
day, err := bind.OptionalInt(r, "day", 0)
if err != nil {
return app.OutlookFilter{}, err
}
if day < 1 || day > 3 {
return app.OutlookFilter{}, apierrors.InvalidParameter("day must be one of [1, 2, 3]")
}
filter.Day = &day
}
outlookType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("outlookType")))
if outlookType != "" {
switch outlookType {
case "categorical", "tornado", "hail", "wind":
filter.OutlookType = outlookType
default:
return app.OutlookFilter{}, apierrors.InvalidParameter("outlookType must be one of [categorical, tornado, hail, wind]")
}
}
return filter, nil
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
// weatherstories_endpoint.go defines the /weatherstories endpoint behavior.
// Layer: adapters/inbound/httpapi weather stories route.
package httpapi
import (
"context"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
func weatherStoriesDefinitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{
endpoint.GET(
"/weatherstories",
bindTimezoneQuery,
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
run, err := svc.LatestWeatherStoryRun(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.WeatherStoryRunPayload(run, req.Units, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("weatherstories.txt.tmpl"),
),
endpoint.GET(
"/weatherstories/latest",
bindTimezoneQuery,
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
story, err := svc.LatestWeatherStory(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.WeatherStoryPayload(story, req.Units, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("weatherstories_latest.txt.tmpl"),
),
}
}

View File

@@ -0,0 +1,70 @@
// alerts_mapper.go maps alert rows into weather model payloads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
func mapAlertRunParentRow(row alertRunParentRow) model.WeatherAlertRun {
return model.WeatherAlertRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
AsOf: row.AsOf.UTC(),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
}
}
func mapAlertRow(row alertRow) indexedAlert {
return indexedAlert{
Index: row.AlertIndex,
Alert: model.WeatherAlert{
ID: row.AlertID,
Event: stringValue(row.Event),
Headline: stringValue(row.Headline),
Severity: stringValue(row.Severity),
Urgency: stringValue(row.Urgency),
Certainty: stringValue(row.Certainty),
Status: stringValue(row.Status),
MessageType: stringValue(row.MessageType),
Category: stringValue(row.Category),
Response: stringValue(row.Response),
Description: stringValue(row.Description),
Instruction: stringValue(row.Instruction),
Sent: timePtr(row.Sent),
Effective: timePtr(row.Effective),
Onset: timePtr(row.Onset),
Expires: timePtr(row.Expires),
AreaDescription: stringValue(row.AreaDescription),
SenderName: stringValue(row.SenderName),
},
}
}
func mapAlertReferenceRow(row alertReferenceRow) indexedAlertReference {
return indexedAlertReference{
AlertIndex: row.AlertIndex,
Reference: model.AlertReference{
ID: stringValue(row.ID),
Identifier: stringValue(row.Identifier),
Sender: stringValue(row.Sender),
Sent: timePtr(row.Sent),
},
}
}
func attachAlertReferences(alerts []indexedAlert, references []indexedAlertReference) []model.WeatherAlert {
refsByAlertIndex := make(map[int][]model.AlertReference, len(alerts))
for _, ref := range references {
refsByAlertIndex[ref.AlertIndex] = append(refsByAlertIndex[ref.AlertIndex], ref.Reference)
}
out := make([]model.WeatherAlert, 0, len(alerts))
for _, alert := range alerts {
mapped := alert.Alert
if refs := refsByAlertIndex[alert.Index]; len(refs) > 0 {
mapped.References = refs
}
out = append(out, mapped)
}
return out
}

View File

@@ -0,0 +1,53 @@
// alerts_queries.go contains SQL text for alert-run reads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
const (
queryLatestAlertRun = `
SELECT
event_id,
location_id,
location_name,
as_of,
latitude,
longitude
FROM alert_runs
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1`
queryAlerts = `
SELECT
alert_index,
alert_id,
event,
headline,
severity,
urgency,
certainty,
status,
message_type,
category,
response,
description,
instruction,
sent,
effective,
onset,
expires,
area_description,
sender_name
FROM alerts
WHERE run_event_id = $1
ORDER BY alert_index ASC`
queryAlertReferences = `
SELECT
alert_index,
id,
identifier,
sender,
sent
FROM alert_references
WHERE run_event_id = $1
ORDER BY alert_index ASC, reference_index ASC`
)

View File

@@ -0,0 +1,110 @@
// alerts_read.go executes alert-run, alerts, and reference queries.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row alertRunParentRow
err := r.db.QueryRowContext(ctx, queryLatestAlertRun).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.AsOf,
&row.Latitude,
&row.Longitude,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest alert run: %w", err)
}
run := mapAlertRunParentRow(row)
alerts, err := r.loadAlerts(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Alerts = alerts
return &run, nil
}
func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.WeatherAlert, error) {
alertsRows, err := r.db.QueryContext(ctx, queryAlerts, eventID)
if err != nil {
return nil, fmt.Errorf("query alerts: %w", err)
}
defer alertsRows.Close()
indexedAlerts := make([]indexedAlert, 0)
for alertsRows.Next() {
var row alertRow
if err := alertsRows.Scan(
&row.AlertIndex,
&row.AlertID,
&row.Event,
&row.Headline,
&row.Severity,
&row.Urgency,
&row.Certainty,
&row.Status,
&row.MessageType,
&row.Category,
&row.Response,
&row.Description,
&row.Instruction,
&row.Sent,
&row.Effective,
&row.Onset,
&row.Expires,
&row.AreaDescription,
&row.SenderName,
); err != nil {
return nil, fmt.Errorf("scan alerts row: %w", err)
}
indexedAlerts = append(indexedAlerts, mapAlertRow(row))
}
if err := alertsRows.Err(); err != nil {
return nil, fmt.Errorf("iterate alerts rows: %w", err)
}
referenceRows, err := r.db.QueryContext(ctx, queryAlertReferences, eventID)
if err != nil {
return nil, fmt.Errorf("query alert references: %w", err)
}
defer referenceRows.Close()
indexedReferences := make([]indexedAlertReference, 0)
for referenceRows.Next() {
var row alertReferenceRow
if err := referenceRows.Scan(
&row.AlertIndex,
&row.ID,
&row.Identifier,
&row.Sender,
&row.Sent,
); err != nil {
return nil, fmt.Errorf("scan alert reference row: %w", err)
}
indexedReferences = append(indexedReferences, mapAlertReferenceRow(row))
}
if err := referenceRows.Err(); err != nil {
return nil, fmt.Errorf("iterate alert reference rows: %w", err)
}
return attachAlertReferences(indexedAlerts, indexedReferences), nil
}

View File

@@ -0,0 +1,59 @@
// alerts_rows.go defines row DTOs for alert-run, alert, and reference reads.
// Layer: adapters/outbound/postgres alerts feature.
package postgres
import (
"database/sql"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type alertRunParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
AsOf time.Time
Latitude sql.NullFloat64
Longitude sql.NullFloat64
}
type alertRow struct {
AlertIndex int
AlertID string
Event sql.NullString
Headline sql.NullString
Severity sql.NullString
Urgency sql.NullString
Certainty sql.NullString
Status sql.NullString
MessageType sql.NullString
Category sql.NullString
Response sql.NullString
Description sql.NullString
Instruction sql.NullString
Sent sql.NullTime
Effective sql.NullTime
Onset sql.NullTime
Expires sql.NullTime
AreaDescription sql.NullString
SenderName sql.NullString
}
type indexedAlert struct {
Index int
Alert model.WeatherAlert
}
type alertReferenceRow struct {
AlertIndex int
ID sql.NullString
Identifier sql.NullString
Sender sql.NullString
Sent sql.NullTime
}
type indexedAlertReference struct {
AlertIndex int
Reference model.AlertReference
}

View File

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

View File

@@ -0,0 +1,50 @@
// conditions_queries.go contains SQL text for current-conditions reads.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
const (
queryCurrentConditions = `
WITH windowed AS (
SELECT
temperature_c,
apparent_temperature_c,
dewpoint_c,
relative_humidity_percent,
wind_speed_kmh,
wind_direction_degrees,
condition_code,
is_day,
observed_at
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
)
SELECT
COUNT(*) AS sample_count,
AVG(temperature_c) AS temperature_c,
AVG(apparent_temperature_c) AS apparent_temperature_c,
AVG(dewpoint_c) AS dewpoint_c,
AVG(relative_humidity_percent) AS relative_humidity_percent,
AVG(wind_speed_kmh) AS wind_speed_kmh,
CASE
WHEN atan2d(
AVG(sind(wind_direction_degrees)),
AVG(cosd(wind_direction_degrees))
) < 0
THEN atan2d(
AVG(sind(wind_direction_degrees)),
AVG(cosd(wind_direction_degrees))
) + 360.0
ELSE atan2d(
AVG(sind(wind_direction_degrees)),
AVG(cosd(wind_direction_degrees))
)
END AS wind_direction_degrees,
MAX(condition_code) AS condition_code,
(
SELECT is_day
FROM windowed
ORDER BY observed_at DESC
LIMIT 1
) AS is_day
FROM windowed`
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
// forecast_mapper.go maps forecast rows into weather model payloads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import (
"database/sql"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapForecastParentRow(row forecastParentRow) model.WeatherForecastRun {
return model.WeatherForecastRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
IssuedAt: row.IssuedAt.UTC(),
UpdatedAt: timePtr(row.UpdatedAt),
Product: model.ForecastProduct(row.Product),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
ElevationMeters: float64Ptr(row.ElevationMeters),
}
}
func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod {
return model.WeatherForecastPeriod{
StartTime: row.StartTime.UTC(),
EndTime: row.EndTime.UTC(),
Name: stringValue(row.Name),
IsDay: boolPtr(row.IsDay),
ConditionCode: wmoCodePtr(row.ConditionCode),
TextDescription: stringValue(row.TextDescription),
TemperatureC: float64Ptr(row.TemperatureC),
TemperatureCMin: float64Ptr(row.TemperatureCMin),
TemperatureCMax: float64Ptr(row.TemperatureCMax),
DewpointC: float64Ptr(row.DewpointC),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindGustKmh: float64Ptr(row.WindGustKmh),
BarometricPressurePa: float64Ptr(row.BarometricPressurePa),
VisibilityMeters: float64Ptr(row.VisibilityMeters),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
CloudCoverPercent: float64Ptr(row.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: float64Ptr(row.ProbabilityOfPrecipitationPercent),
PrecipitationAmountMm: float64Ptr(row.PrecipitationAmountMM),
SnowfallDepthMM: float64Ptr(row.SnowfallDepthMM),
UVIndex: float64Ptr(row.UVIndex),
}
}
func wmoCodePtr(v sql.NullInt64) *model.WMOCode {
if !v.Valid {
return nil
}
out := model.WMOCode(v.Int64)
return &out
}

View File

@@ -0,0 +1,66 @@
// forecast_queries.go contains SQL text for forecast reads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
const (
queryLatestHourlyForecast = `
SELECT
event_id,
location_id,
location_name,
issued_at,
updated_at,
product,
latitude,
longitude,
elevation_meters
FROM forecasts
WHERE product = 'hourly'
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryLatestNarrativeForecast = `
SELECT
event_id,
location_id,
location_name,
issued_at,
updated_at,
product,
latitude,
longitude,
elevation_meters
FROM forecasts
WHERE product = 'narrative'
ORDER BY issued_at DESC, event_emitted_at DESC
LIMIT 1`
queryForecastPeriods = `
SELECT
period_index,
start_time,
end_time,
name,
is_day,
condition_code,
text_description,
temperature_c,
temperature_c_min,
temperature_c_max,
dewpoint_c,
relative_humidity_percent,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
apparent_temperature_c,
cloud_cover_percent,
probability_of_precipitation_percent,
precipitation_amount_mm,
snowfall_depth_mm,
uv_index
FROM forecast_periods
WHERE run_event_id = $1
ORDER BY period_index ASC`
)

View File

@@ -0,0 +1,104 @@
// forecast_read.go executes forecast and period queries.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return r.loadLatestForecastRun(ctx, queryLatestHourlyForecast, "hourly")
}
func (r *Repository) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return r.loadLatestForecastRun(ctx, queryLatestNarrativeForecast, "narrative")
}
func (r *Repository) loadLatestForecastRun(
ctx context.Context,
parentQuery string,
productLabel string,
) (*model.WeatherForecastRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row forecastParentRow
err := r.db.QueryRowContext(ctx, parentQuery).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.IssuedAt,
&row.UpdatedAt,
&row.Product,
&row.Latitude,
&row.Longitude,
&row.ElevationMeters,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest %s forecast: %w", productLabel, err)
}
run := mapForecastParentRow(row)
periods, err := r.loadForecastPeriods(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Periods = periods
return &run, nil
}
func (r *Repository) loadForecastPeriods(ctx context.Context, eventID string) ([]model.WeatherForecastPeriod, error) {
rows, err := r.db.QueryContext(ctx, queryForecastPeriods, eventID)
if err != nil {
return nil, fmt.Errorf("query forecast periods: %w", err)
}
defer rows.Close()
out := make([]model.WeatherForecastPeriod, 0)
for rows.Next() {
var row forecastPeriodRow
if err := rows.Scan(
&row.PeriodIndex,
&row.StartTime,
&row.EndTime,
&row.Name,
&row.IsDay,
&row.ConditionCode,
&row.TextDescription,
&row.TemperatureC,
&row.TemperatureCMin,
&row.TemperatureCMax,
&row.DewpointC,
&row.RelativeHumidityPercent,
&row.WindDirectionDegrees,
&row.WindSpeedKmh,
&row.WindGustKmh,
&row.BarometricPressurePa,
&row.VisibilityMeters,
&row.ApparentTemperatureC,
&row.CloudCoverPercent,
&row.ProbabilityOfPrecipitationPercent,
&row.PrecipitationAmountMM,
&row.SnowfallDepthMM,
&row.UVIndex,
); err != nil {
return nil, fmt.Errorf("scan forecast period row: %w", err)
}
out = append(out, mapForecastPeriodRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate forecast period rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,46 @@
// forecast_rows.go defines row DTOs for forecast reads.
// Layer: adapters/outbound/postgres forecast feature.
package postgres
import (
"database/sql"
"time"
)
type forecastParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
IssuedAt time.Time
UpdatedAt sql.NullTime
Product string
Latitude sql.NullFloat64
Longitude sql.NullFloat64
ElevationMeters sql.NullFloat64
}
type forecastPeriodRow struct {
PeriodIndex int
StartTime time.Time
EndTime time.Time
Name sql.NullString
IsDay sql.NullBool
ConditionCode sql.NullInt64
TextDescription sql.NullString
TemperatureC sql.NullFloat64
TemperatureCMin sql.NullFloat64
TemperatureCMax sql.NullFloat64
DewpointC sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindGustKmh sql.NullFloat64
BarometricPressurePa sql.NullFloat64
VisibilityMeters sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
CloudCoverPercent sql.NullFloat64
ProbabilityOfPrecipitationPercent sql.NullFloat64
PrecipitationAmountMM sql.NullFloat64
SnowfallDepthMM sql.NullFloat64
UVIndex sql.NullFloat64
}

View File

@@ -0,0 +1,42 @@
// observations_mapper.go maps observation rows into weather model payloads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"encoding/json"
"strings"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapObservationParentRow(row observationParentRow) model.WeatherObservation {
return model.WeatherObservation{
StationID: stringValue(row.StationID),
StationName: stringValue(row.StationName),
Timestamp: row.ObservedAt.UTC(),
ConditionCode: model.WMOCode(row.ConditionCode),
IsDay: boolPtr(row.IsDay),
TextDescription: stringValue(row.TextDescription),
TemperatureC: float64Ptr(row.TemperatureC),
DewpointC: float64Ptr(row.DewpointC),
WindDirectionDegrees: float64Ptr(row.WindDirectionDegrees),
WindSpeedKmh: float64Ptr(row.WindSpeedKmh),
WindGustKmh: float64Ptr(row.WindGustKmh),
BarometricPressurePa: float64Ptr(row.BarometricPressurePa),
VisibilityMeters: float64Ptr(row.VisibilityMeters),
RelativeHumidityPercent: float64Ptr(row.RelativeHumidityPercent),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
}
}
func mapObservationPresentWeatherRow(row observationPresentWeatherRow) (model.PresentWeather, error) {
if !row.RawText.Valid || strings.TrimSpace(row.RawText.String) == "" {
return model.PresentWeather{}, nil
}
var raw map[string]any
if err := json.Unmarshal([]byte(row.RawText.String), &raw); err != nil {
return model.PresentWeather{}, err
}
return model.PresentWeather{Raw: raw}, nil
}

View File

@@ -0,0 +1,33 @@
// observations_queries.go contains SQL text for observation reads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
const (
queryLatestObservation = `
SELECT
event_id,
station_id,
station_name,
observed_at,
condition_code,
is_day,
text_description,
temperature_c,
dewpoint_c,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
relative_humidity_percent,
apparent_temperature_c
FROM observations
ORDER BY observed_at DESC, event_emitted_at DESC
LIMIT 1`
queryObservationPresentWeather = `
SELECT weather_index, raw_text
FROM observation_present_weather
WHERE event_id = $1
ORDER BY weather_index ASC`
)

View File

@@ -0,0 +1,79 @@
// observations_read.go executes observation and present-weather queries.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row observationParentRow
err := r.db.QueryRowContext(ctx, queryLatestObservation).Scan(
&row.EventID,
&row.StationID,
&row.StationName,
&row.ObservedAt,
&row.ConditionCode,
&row.IsDay,
&row.TextDescription,
&row.TemperatureC,
&row.DewpointC,
&row.WindDirectionDegrees,
&row.WindSpeedKmh,
&row.WindGustKmh,
&row.BarometricPressurePa,
&row.VisibilityMeters,
&row.RelativeHumidityPercent,
&row.ApparentTemperatureC,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest observation: %w", err)
}
obs := mapObservationParentRow(row)
presentWeather, err := r.loadObservationPresentWeather(ctx, row.EventID)
if err != nil {
return nil, err
}
obs.PresentWeather = presentWeather
return &obs, nil
}
func (r *Repository) loadObservationPresentWeather(ctx context.Context, eventID string) ([]model.PresentWeather, error) {
rows, err := r.db.QueryContext(ctx, queryObservationPresentWeather, eventID)
if err != nil {
return nil, fmt.Errorf("query observation present weather: %w", err)
}
defer rows.Close()
out := make([]model.PresentWeather, 0)
for rows.Next() {
var row observationPresentWeatherRow
if err := rows.Scan(&row.WeatherIndex, &row.RawText); err != nil {
return nil, fmt.Errorf("scan observation present weather row: %w", err)
}
pw, err := mapObservationPresentWeatherRow(row)
if err != nil {
return nil, fmt.Errorf("decode observation present weather row (index=%d): %w", row.WeatherIndex, err)
}
out = append(out, pw)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation present weather rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,32 @@
// observations_rows.go defines row DTOs for observation reads.
// Layer: adapters/outbound/postgres observations feature.
package postgres
import (
"database/sql"
"time"
)
type observationParentRow struct {
EventID string
StationID sql.NullString
StationName sql.NullString
ObservedAt time.Time
ConditionCode int
IsDay sql.NullBool
TextDescription sql.NullString
TemperatureC sql.NullFloat64
DewpointC sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindGustKmh sql.NullFloat64
BarometricPressurePa sql.NullFloat64
VisibilityMeters sql.NullFloat64
RelativeHumidityPercent sql.NullFloat64
ApparentTemperatureC sql.NullFloat64
}
type observationPresentWeatherRow struct {
WeatherIndex int
RawText sql.NullString
}

View File

@@ -0,0 +1,67 @@
// outlooks_mapper.go maps outlook rows into weather model payloads.
// Layer: adapters/outbound/postgres outlook feature.
package postgres
import (
"database/sql"
"encoding/json"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapOutlookRunParentRow(row outlookRunParentRow) model.WeatherOutlookRun {
return model.WeatherOutlookRun{
LocationID: stringValue(row.LocationID),
LocationName: stringValue(row.LocationName),
Latitude: float64Ptr(row.Latitude),
Longitude: float64Ptr(row.Longitude),
AsOf: row.AsOf.UTC(),
IssuedAt: timePtr(row.IssuedAt),
}
}
func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
geometry := []byte(row.GeometryJSON)
if !json.Valid(geometry) {
return model.WeatherOutlook{}, fmt.Errorf("decode outlook geometry: invalid JSON")
}
return model.WeatherOutlook{
ID: row.OutlookID,
Provider: row.Provider,
Product: row.Product,
Day: row.Day,
OutlookType: row.OutlookType,
Label: row.Label,
LabelText: stringValue(row.LabelText),
SeverityRank: intPtr(row.SeverityRank),
ValidFrom: row.ValidFrom.UTC(),
ValidTo: row.ValidTo.UTC(),
IssuedAt: row.IssuedAt.UTC(),
ExpiresAt: row.ExpiresAt.UTC(),
Forecaster: stringValue(row.Forecaster),
SourceURL: stringValue(row.SourceURL),
ImageURL: stringValue(row.ImageURL),
ContainsLocation: row.ContainsLocation,
Geometry: json.RawMessage(append([]byte(nil), geometry...)),
}, nil
}
func mapOutlookDiscussionRow(row outlookDiscussionRow) model.WeatherOutlookDiscussion {
return model.WeatherOutlookDiscussion{
Day: row.Day,
Headline: stringValue(row.Headline),
Summary: stringValue(row.Summary),
Discussion: stringValue(row.Discussion),
UpdatedAt: timePtr(row.UpdatedAt),
}
}
func intPtr(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
out := int(v.Int64)
return &out
}

View File

@@ -0,0 +1,216 @@
// outlooks_mapper_test.go validates outlook row mapping.
// Layer: adapters/outbound/postgres outlook mapper tests.
package postgres
import (
"database/sql"
"testing"
"time"
)
func TestMapOutlookRunParentRowNullables(t *testing.T) {
asOf := time.Date(2026, 6, 11, 11, 30, 0, 0, time.FixedZone("CDT", -5*3600))
issuedAt := asOf.Add(-30 * time.Minute)
latitude := 38.62
run := mapOutlookRunParentRow(outlookRunParentRow{
EventID: "evt-outlook-run",
LocationID: sql.NullString{String: "stl", Valid: true},
LocationName: sql.NullString{String: "St. Louis", Valid: true},
Latitude: sql.NullFloat64{Float64: latitude, Valid: true},
Longitude: sql.NullFloat64{Valid: false},
AsOf: asOf,
IssuedAt: sql.NullTime{Time: issuedAt, Valid: true},
})
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
t.Fatalf("unexpected location metadata: %+v", run)
}
if run.Latitude == nil || *run.Latitude != latitude {
t.Fatalf("expected latitude pointer %v, got %v", latitude, run.Latitude)
}
if run.Longitude != nil {
t.Fatalf("expected nil longitude, got %v", *run.Longitude)
}
if run.AsOf.Location().String() != "UTC" {
t.Fatalf("expected asOf UTC normalization, got %s", run.AsOf.Location())
}
if run.IssuedAt == nil || run.IssuedAt.Location().String() != "UTC" {
t.Fatalf("expected issuedAt UTC pointer, got %v", run.IssuedAt)
}
if run.Outlooks != nil {
t.Fatalf("expected nil outlooks before child load, got %+v", run.Outlooks)
}
}
func TestMapOutlookRunParentRowMissingOptionals(t *testing.T) {
run := mapOutlookRunParentRow(outlookRunParentRow{
AsOf: time.Date(2026, 6, 11, 16, 30, 0, 0, time.UTC),
})
if run.LocationID != "" || run.LocationName != "" {
t.Fatalf("expected empty location metadata, got %+v", run)
}
if run.Latitude != nil || run.Longitude != nil || run.IssuedAt != nil {
t.Fatalf("expected nil optional pointers, got %+v", run)
}
}
func TestMapOutlookRowMapsFields(t *testing.T) {
validFrom := time.Date(2026, 6, 11, 7, 0, 0, 0, time.FixedZone("CDT", -5*3600))
validTo := validFrom.Add(12 * time.Hour)
issuedAt := validFrom.Add(-1 * time.Hour)
expiresAt := validTo
severityRank := int64(5)
geometry := `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`
outlook, err := mapOutlookRow(outlookRow{
OutlookIndex: 3,
OutlookID: "spc-20260611-1300-day1-cat-slight",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: sql.NullString{String: "Slight Risk", Valid: true},
SeverityRank: sql.NullInt64{Int64: severityRank, Valid: true},
ValidFrom: validFrom,
ValidTo: validTo,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
Forecaster: sql.NullString{String: "DIAL", Valid: true},
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
ContainsLocation: true,
GeometryJSON: geometry,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outlook.ID != "spc-20260611-1300-day1-cat-slight" {
t.Fatalf("unexpected outlook id: %q", outlook.ID)
}
if outlook.Provider != "spc" || outlook.Product != "convective" || outlook.Day != 1 || outlook.OutlookType != "categorical" {
t.Fatalf("unexpected core fields: %+v", outlook)
}
if outlook.Label != "SLGT" || outlook.LabelText != "Slight Risk" {
t.Fatalf("unexpected label fields: %+v", outlook)
}
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
}
if outlook.Forecaster != "DIAL" {
t.Fatalf("unexpected forecaster: %+v", outlook)
}
if outlook.SourceURL == "" || outlook.ImageURL == "" {
t.Fatalf("expected source and image URLs: %+v", outlook)
}
if !outlook.ContainsLocation {
t.Fatal("expected containsLocation true")
}
if outlook.ValidFrom.Location().String() != "UTC" ||
outlook.ValidTo.Location().String() != "UTC" ||
outlook.IssuedAt.Location().String() != "UTC" ||
outlook.ExpiresAt.Location().String() != "UTC" {
t.Fatalf("expected UTC timestamps, got %s %s %s %s", outlook.ValidFrom, outlook.ValidTo, outlook.IssuedAt, outlook.ExpiresAt)
}
if string(outlook.Geometry) != geometry {
t.Fatalf("expected geometry bytes preserved, got %s", outlook.Geometry)
}
}
func TestMapOutlookRowMissingOptionals(t *testing.T) {
outlook, err := mapOutlookRow(outlookRow{
OutlookID: "tor-1",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "tornado",
Label: "2%",
ValidFrom: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
ValidTo: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
IssuedAt: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC),
ExpiresAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
ContainsLocation: false,
GeometryJSON: `{"type":"Point","coordinates":[-90.2,38.6]}`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
}
if outlook.SeverityRank != nil {
t.Fatalf("expected nil severity rank, got %v", *outlook.SeverityRank)
}
if outlook.ContainsLocation {
t.Fatal("expected containsLocation false")
}
}
func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
_, err := mapOutlookRow(outlookRow{
OutlookID: "bad-geometry",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
ValidFrom: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
ValidTo: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
IssuedAt: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC),
ExpiresAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
GeometryJSON: `{"type":"Point"`,
})
if err == nil {
t.Fatal("expected invalid geometry error")
}
}
func TestMapOutlookDiscussionRowMapsFields(t *testing.T) {
updatedAt := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("CDT", -5*3600))
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
DiscussionIndex: 2,
Day: 2,
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
})
if discussion.Day != 2 {
t.Fatalf("expected day 2, got %d", discussion.Day)
}
if discussion.Headline != "Severe storms possible" {
t.Fatalf("unexpected headline: %q", discussion.Headline)
}
if discussion.Summary != "Scattered severe storms are possible." {
t.Fatalf("unexpected summary: %q", discussion.Summary)
}
if discussion.Discussion != "Discussion text." {
t.Fatalf("unexpected discussion: %q", discussion.Discussion)
}
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected updatedAt UTC pointer, got %v", discussion.UpdatedAt)
}
}
func TestMapOutlookDiscussionRowMissingOptionals(t *testing.T) {
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
DiscussionIndex: 1,
Day: 1,
})
if discussion.Day != 1 {
t.Fatalf("expected day 1, got %d", discussion.Day)
}
if discussion.Headline != "" || discussion.Summary != "" || discussion.Discussion != "" {
t.Fatalf("expected empty optional strings, got %+v", discussion)
}
if discussion.UpdatedAt != nil {
t.Fatalf("expected nil updatedAt, got %v", discussion.UpdatedAt)
}
}

View File

@@ -0,0 +1,54 @@
// outlooks_queries.go contains SQL text for outlook reads.
// Layer: adapters/outbound/postgres outlook feature.
package postgres
const (
queryLatestConvectiveOutlookRun = `
SELECT
event_id,
location_id,
location_name,
latitude,
longitude,
as_of,
issued_at
FROM outlook_runs
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1`
queryOutlooksForRun = `
SELECT
outlook_index,
outlook_id,
provider,
product,
day,
outlook_type,
label,
label_text,
severity_rank,
valid_from,
valid_to,
issued_at,
expires_at,
forecaster,
source_url,
image_url,
contains_location,
geometry_json
FROM outlooks
WHERE run_event_id = $1
ORDER BY outlook_index ASC`
queryOutlookDiscussionsForRun = `
SELECT
discussion_index,
day,
headline,
summary,
discussion,
updated_at
FROM outlook_discussions
WHERE run_event_id = $1
ORDER BY discussion_index ASC`
)

View File

@@ -0,0 +1,124 @@
// outlooks_read.go executes outlook-run and outlook queries.
// Layer: adapters/outbound/postgres outlook feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.WeatherOutlookRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row outlookRunParentRow
err := r.db.QueryRowContext(ctx, queryLatestConvectiveOutlookRun).Scan(
&row.EventID,
&row.LocationID,
&row.LocationName,
&row.Latitude,
&row.Longitude,
&row.AsOf,
&row.IssuedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest convective outlook run: %w", err)
}
run := mapOutlookRunParentRow(row)
outlooks, err := r.loadOutlooks(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Outlooks = outlooks
discussions, err := r.loadOutlookDiscussions(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Discussions = discussions
return &run, nil
}
func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.WeatherOutlook, error) {
rows, err := r.db.QueryContext(ctx, queryOutlooksForRun, eventID)
if err != nil {
return nil, fmt.Errorf("query outlooks: %w", err)
}
defer rows.Close()
out := make([]model.WeatherOutlook, 0)
for rows.Next() {
var row outlookRow
if err := rows.Scan(
&row.OutlookIndex,
&row.OutlookID,
&row.Provider,
&row.Product,
&row.Day,
&row.OutlookType,
&row.Label,
&row.LabelText,
&row.SeverityRank,
&row.ValidFrom,
&row.ValidTo,
&row.IssuedAt,
&row.ExpiresAt,
&row.Forecaster,
&row.SourceURL,
&row.ImageURL,
&row.ContainsLocation,
&row.GeometryJSON,
); err != nil {
return nil, fmt.Errorf("scan outlook row: %w", err)
}
outlook, err := mapOutlookRow(row)
if err != nil {
return nil, fmt.Errorf("map outlook row: %w", err)
}
out = append(out, outlook)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate outlook rows: %w", err)
}
return out, nil
}
func (r *Repository) loadOutlookDiscussions(ctx context.Context, eventID string) ([]model.WeatherOutlookDiscussion, error) {
rows, err := r.db.QueryContext(ctx, queryOutlookDiscussionsForRun, eventID)
if err != nil {
return nil, fmt.Errorf("query outlook discussions: %w", err)
}
defer rows.Close()
out := make([]model.WeatherOutlookDiscussion, 0)
for rows.Next() {
var row outlookDiscussionRow
if err := rows.Scan(
&row.DiscussionIndex,
&row.Day,
&row.Headline,
&row.Summary,
&row.Discussion,
&row.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan outlook discussion row: %w", err)
}
out = append(out, mapOutlookDiscussionRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate outlook discussion rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,405 @@
// outlooks_read_test.go validates outlook repository read flow.
// Layer: adapters/outbound/postgres outlook read tests.
package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
)
const outlookTestDriverName = "weatherapi_outlook_read_test"
func init() {
sql.Register(outlookTestDriverName, outlookTestDriver{})
}
func TestLatestConvectiveOutlookRunLoadsOutlooksAndDiscussions(t *testing.T) {
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
issuedAt := asOf.Add(-1 * time.Hour)
discussionUpdated := asOf.Add(-30 * time.Minute)
repo, closeDB := openOutlookTestRepository(t,
outlookParentQuery([][]driver.Value{{
"evt-outlook-run",
"stl",
"St. Louis",
float64(38.62),
float64(-90.2),
asOf,
issuedAt,
}}),
outlookRowsQuery([][]driver.Value{
outlookReadRow(1, "day-1", 1, "categorical"),
outlookReadRow(2, "day-2", 2, "wind"),
}, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), int64(1), "Day 1 headline", "Day 1 summary", "Day 1 discussion", discussionUpdated},
{int64(2), int64(2), "Day 2 headline", nil, "Day 2 discussion", nil},
}, nil),
)
defer closeDB()
run, err := repo.LatestConvectiveOutlookRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil {
t.Fatal("expected outlook run")
}
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
t.Fatalf("unexpected run metadata: %+v", run)
}
if len(run.Outlooks) != 2 {
t.Fatalf("expected 2 outlooks, got %d", len(run.Outlooks))
}
if run.Outlooks[0].ID != "day-1" || run.Outlooks[1].ID != "day-2" {
t.Fatalf("expected outlook order from rows, got %+v", run.Outlooks)
}
if len(run.Discussions) != 2 {
t.Fatalf("expected 2 discussions, got %d", len(run.Discussions))
}
if run.Discussions[0].Day != 1 || run.Discussions[0].Headline != "Day 1 headline" {
t.Fatalf("unexpected first discussion: %+v", run.Discussions[0])
}
if run.Discussions[1].Day != 2 || run.Discussions[1].Summary != "" {
t.Fatalf("unexpected second discussion: %+v", run.Discussions[1])
}
if run.Discussions[0].UpdatedAt == nil || run.Discussions[0].UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected discussion updatedAt UTC pointer, got %v", run.Discussions[0].UpdatedAt)
}
if run.Discussions[1].UpdatedAt != nil {
t.Fatalf("expected nil discussion updatedAt, got %v", run.Discussions[1].UpdatedAt)
}
assertOutlookTestQueriesConsumed(t)
}
func TestLatestConvectiveOutlookRunMissingParentReturnsNil(t *testing.T) {
repo, closeDB := openOutlookTestRepository(t, outlookParentQuery(nil))
defer closeDB()
run, err := repo.LatestConvectiveOutlookRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run != nil {
t.Fatalf("expected nil run, got %+v", run)
}
assertOutlookTestQueriesConsumed(t)
}
func TestLatestConvectiveOutlookRunWrapsReadErrors(t *testing.T) {
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
tests := []struct {
name string
queries []scriptedOutlookQuery
want string
}{
{
name: "parent query",
queries: []scriptedOutlookQuery{
{name: "parent", query: queryLatestConvectiveOutlookRun, err: errors.New("parent unavailable")},
},
want: "query latest convective outlook run",
},
{
name: "outlooks query",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
{name: "outlooks", query: queryOutlooksForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("outlooks unavailable")},
},
want: "query outlooks",
},
{
name: "outlook scan",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
{int64(1), "day-1"},
}, nil),
},
want: "scan outlook row",
},
{
name: "outlook map",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
outlookReadRowWithGeometry(1, "day-1", 1, "categorical", `{"type":"Point"`),
}, nil),
},
want: "map outlook row",
},
{
name: "outlook iteration",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
outlookReadRow(1, "day-1", 1, "categorical"),
}, errors.New("outlook iteration failed")),
},
want: "iterate outlook rows",
},
{
name: "discussions query",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
{name: "discussions", query: queryOutlookDiscussionsForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("discussions unavailable")},
},
want: "query outlook discussions",
},
{
name: "discussion scan",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), "bad day", nil, nil, nil, nil},
}, nil),
},
want: "scan outlook discussion row",
},
{
name: "discussion iteration",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), int64(1), nil, nil, nil, nil},
}, errors.New("discussion iteration failed")),
},
want: "iterate outlook discussion rows",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo, closeDB := openOutlookTestRepository(t, tt.queries...)
defer closeDB()
_, err := repo.LatestConvectiveOutlookRun(context.Background())
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("expected error containing %q, got %v", tt.want, err)
}
})
}
}
func openOutlookTestRepository(t *testing.T, queries ...scriptedOutlookQuery) (*Repository, func()) {
t.Helper()
outlookTestScript.set(queries)
db, err := sql.Open(outlookTestDriverName, "")
if err != nil {
t.Fatalf("open test db: %v", err)
}
db.SetMaxOpenConns(1)
return NewRepository(db), func() {
_ = db.Close()
outlookTestScript.set(nil)
}
}
func assertOutlookTestQueriesConsumed(t *testing.T) {
t.Helper()
if remaining := outlookTestScript.remaining(); remaining != 0 {
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
}
}
func outlookParentQuery(rows [][]driver.Value) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "parent",
query: queryLatestConvectiveOutlookRun,
columns: []string{"event_id", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at"},
rows: rows,
}
}
func outlookRowsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "outlooks",
query: queryOutlooksForRun,
args: []driver.Value{"evt-outlook-run"},
columns: []string{"outlook_index", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "source_url", "image_url", "contains_location", "geometry_json"},
rows: rows,
nextErr: nextErr,
}
}
func outlookDiscussionsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "discussions",
query: queryOutlookDiscussionsForRun,
args: []driver.Value{"evt-outlook-run"},
columns: []string{"discussion_index", "day", "headline", "summary", "discussion", "updated_at"},
rows: rows,
nextErr: nextErr,
}
}
func outlookReadRow(index int64, id string, day int64, outlookType string) []driver.Value {
return outlookReadRowWithGeometry(index, id, day, outlookType, `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`)
}
func outlookReadRowWithGeometry(index int64, id string, day int64, outlookType string, geometry string) []driver.Value {
validFrom := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
validTo := validFrom.Add(6 * time.Hour)
return []driver.Value{
index,
id,
"spc",
"convective",
day,
outlookType,
"SLGT",
"Slight Risk",
int64(5),
validFrom,
validTo,
validFrom.Add(-1 * time.Hour),
validTo,
"DIAL",
"https://example.test/source",
"https://example.test/image.png",
true,
geometry,
}
}
type outlookTestDriver struct{}
func (outlookTestDriver) Open(string) (driver.Conn, error) {
return outlookTestConn{}, nil
}
type outlookTestConn struct{}
func (outlookTestConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (outlookTestConn) Close() error {
return nil
}
func (outlookTestConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions are not supported")
}
func (outlookTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
return outlookTestScript.next(query, args)
}
type scriptedOutlookQuery struct {
name string
query string
args []driver.Value
columns []string
rows [][]driver.Value
err error
nextErr error
}
type outlookTestScriptState struct {
mu sync.Mutex
queries []scriptedOutlookQuery
}
var outlookTestScript outlookTestScriptState
func (s *outlookTestScriptState) set(queries []scriptedOutlookQuery) {
s.mu.Lock()
defer s.mu.Unlock()
s.queries = append([]scriptedOutlookQuery(nil), queries...)
}
func (s *outlookTestScriptState) remaining() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.queries)
}
func (s *outlookTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.queries) == 0 {
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
}
next := s.queries[0]
s.queries = s.queries[1:]
if compactSQL(query) != compactSQL(next.query) {
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
}
if len(args) != len(next.args) {
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
}
for i, arg := range args {
if arg.Value != next.args[i] {
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
}
}
if next.err != nil {
return nil, next.err
}
return &outlookTestRows{
columns: append([]string(nil), next.columns...),
rows: append([][]driver.Value(nil), next.rows...),
nextErr: next.nextErr,
}, nil
}
type outlookTestRows struct {
columns []string
rows [][]driver.Value
index int
nextErr error
}
func (r *outlookTestRows) Columns() []string {
return r.columns
}
func (r *outlookTestRows) Close() error {
return nil
}
func (r *outlookTestRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
if r.nextErr != nil {
err := r.nextErr
r.nextErr = nil
return err
}
return io.EOF
}
copy(dest, r.rows[r.index])
r.index++
return nil
}
func compactSQL(query string) string {
return strings.Join(strings.Fields(query), " ")
}
func namedValues(args []driver.NamedValue) []driver.Value {
out := make([]driver.Value, len(args))
for i := range args {
out[i] = args[i].Value
}
return out
}

View File

@@ -0,0 +1,48 @@
// outlooks_rows.go defines row DTOs for outlook reads.
// Layer: adapters/outbound/postgres outlook feature.
package postgres
import (
"database/sql"
"time"
)
type outlookRunParentRow struct {
EventID string
LocationID sql.NullString
LocationName sql.NullString
Latitude sql.NullFloat64
Longitude sql.NullFloat64
AsOf time.Time
IssuedAt sql.NullTime
}
type outlookRow struct {
OutlookIndex int
OutlookID string
Provider string
Product string
Day int
OutlookType string
Label string
LabelText sql.NullString
SeverityRank sql.NullInt64
ValidFrom time.Time
ValidTo time.Time
IssuedAt time.Time
ExpiresAt time.Time
Forecaster sql.NullString
SourceURL sql.NullString
ImageURL sql.NullString
ContainsLocation bool
GeometryJSON string
}
type outlookDiscussionRow struct {
DiscussionIndex int
Day int
Headline sql.NullString
Summary sql.NullString
Discussion sql.NullString
UpdatedAt sql.NullTime
}

View File

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

View File

@@ -0,0 +1,298 @@
// repository_test.go validates Postgres row mapping and attachment helpers.
// Layer: adapters/outbound/postgres mapper regression tests.
package postgres
import (
"database/sql"
"testing"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func TestMapObservationParentRowNullables(t *testing.T) {
observedAt := time.Date(2026, 3, 19, 23, 45, 0, 0, time.FixedZone("CST", -6*3600))
isDay := true
temp := 18.25
obs := mapObservationParentRow(observationParentRow{
StationID: sql.NullString{String: "KSTL", Valid: true},
StationName: sql.NullString{String: "St. Louis", Valid: true},
ObservedAt: observedAt,
ConditionCode: 2,
IsDay: sql.NullBool{Bool: isDay, Valid: true},
TemperatureC: sql.NullFloat64{Float64: temp, Valid: true},
TextDescription: sql.NullString{String: "Partly Cloudy", Valid: true},
})
if obs.StationID != "KSTL" {
t.Fatalf("expected station id KSTL, got %q", obs.StationID)
}
if obs.IsDay == nil || !*obs.IsDay {
t.Fatalf("expected isDay pointer true, got %v", obs.IsDay)
}
if obs.TemperatureC == nil || *obs.TemperatureC != temp {
t.Fatalf("expected temperature %v, got %v", temp, obs.TemperatureC)
}
if obs.DewpointC != nil {
t.Fatalf("expected nil dewpoint, got %v", *obs.DewpointC)
}
if got := obs.Timestamp.Location().String(); got != "UTC" {
t.Fatalf("expected UTC timestamp, got %s", got)
}
}
func TestMapObservationPresentWeatherRow(t *testing.T) {
row := observationPresentWeatherRow{
WeatherIndex: 1,
RawText: sql.NullString{String: `{"code":61,"text":"rain"}`, Valid: true},
}
pw, err := mapObservationPresentWeatherRow(row)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pw.Raw == nil {
t.Fatalf("expected raw map to be populated")
}
if got, ok := pw.Raw["text"].(string); !ok || got != "rain" {
t.Fatalf("expected raw text rain, got %#v", pw.Raw["text"])
}
}
func TestMapForecastPeriodRowNullables(t *testing.T) {
start := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
end := start.Add(1 * time.Hour)
period := mapForecastPeriodRow(forecastPeriodRow{
StartTime: start,
EndTime: end,
ConditionCode: sql.NullInt64{Int64: 80, Valid: true},
Name: sql.NullString{String: "Midnight", Valid: true},
TemperatureC: sql.NullFloat64{Float64: 12.5, Valid: true},
TemperatureCMin: sql.NullFloat64{Valid: false},
})
if period.Name != "Midnight" {
t.Fatalf("expected period name Midnight, got %q", period.Name)
}
if period.TemperatureC == nil || *period.TemperatureC != 12.5 {
t.Fatalf("expected temperature pointer 12.5, got %v", period.TemperatureC)
}
if period.TemperatureCMin != nil {
t.Fatalf("expected nil TemperatureCMin, got %v", *period.TemperatureCMin)
}
if !period.StartTime.Equal(start) || !period.EndTime.Equal(end) {
t.Fatalf("unexpected time range: %s - %s", period.StartTime, period.EndTime)
}
if period.ConditionCode == nil || *period.ConditionCode != 80 {
t.Fatalf("expected condition code pointer 80, got %v", period.ConditionCode)
}
}
func TestMapForecastPeriodRowConditionCodeNullable(t *testing.T) {
period := mapForecastPeriodRow(forecastPeriodRow{
StartTime: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC),
ConditionCode: sql.NullInt64{Valid: false},
})
if period.ConditionCode != nil {
t.Fatalf("expected nil condition code, got %v", period.ConditionCode)
}
}
func TestMapDiscussionParentRowNullables(t *testing.T) {
issuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.FixedZone("CDT", -5*3600))
updatedAt := issuedAt.Add(time.Hour)
shortIssuedAt := issuedAt.Add(-5 * time.Minute)
discussion := mapDiscussionParentRow(discussionParentRow{
OfficeID: sql.NullString{String: "LSX", Valid: true},
OfficeName: sql.NullString{String: "National Weather Service Saint Louis MO", Valid: true},
IssuedAt: issuedAt,
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
Product: "afd",
ShortTermQualifier: sql.NullString{String: "(Tonight)", Valid: true},
ShortTermIssuedAt: sql.NullTime{Time: shortIssuedAt, Valid: true},
ShortTermText: sql.NullString{String: "Short term text", Valid: true},
})
if discussion.OfficeID != "LSX" {
t.Fatalf("expected office id LSX, got %q", discussion.OfficeID)
}
if discussion.Product != model.ForecastDiscussionProductAFD {
t.Fatalf("expected product afd, got %q", discussion.Product)
}
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected updatedAt to be UTC-normalized, got %v", discussion.UpdatedAt)
}
if discussion.ShortTerm == nil {
t.Fatalf("expected shortTerm section to be populated")
}
if discussion.ShortTerm.Qualifier != "(Tonight)" || discussion.ShortTerm.Text != "Short term text" {
t.Fatalf("unexpected shortTerm section: %+v", discussion.ShortTerm)
}
if discussion.LongTerm != nil {
t.Fatalf("expected longTerm nil, got %+v", discussion.LongTerm)
}
}
func TestMapDiscussionSectionNilWhenAllFieldsMissing(t *testing.T) {
got := discussionSectionPtr(sql.NullString{}, sql.NullTime{}, sql.NullString{})
if got != nil {
t.Fatalf("expected nil discussion section, got %+v", got)
}
}
func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) {
rows := []discussionKeyMessageRow{
{MessageIndex: 0, MessageText: sql.NullString{String: "first", Valid: true}},
{MessageIndex: 1, MessageText: sql.NullString{String: "second", Valid: true}},
}
got := make([]string, 0, len(rows))
for _, row := range rows {
got = append(got, mapDiscussionKeyMessageRow(row))
}
if len(got) != 2 || got[0] != "first" || got[1] != "second" {
t.Fatalf("unexpected key message order: %#v", got)
}
}
func TestMapWeatherStoryRunParentRowNullables(t *testing.T) {
asOf := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600))
run := mapWeatherStoryRunParentRow(weatherStoryRunParentRow{
EventID: "evt-story-run",
OfficeID: sql.NullString{String: "LSX", Valid: true},
AsOf: asOf,
})
if run.OfficeID != "LSX" {
t.Fatalf("expected office id LSX, got %q", run.OfficeID)
}
if run.AsOf.Location().String() != "UTC" {
t.Fatalf("expected asOf to be UTC-normalized, got %v", run.AsOf)
}
if run.Stories != nil {
t.Fatalf("expected nil stories before child load, got %+v", run.Stories)
}
}
func TestMapWeatherStoryRowMapsFields(t *testing.T) {
start := time.Date(2026, 5, 30, 8, 46, 0, 0, time.FixedZone("CDT", -5*3600))
end := time.Date(2026, 5, 31, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600))
updated := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600))
story := mapWeatherStoryRow(weatherStoryRow{
StoryIndex: 2,
OfficeID: sql.NullString{String: "LSX", Valid: true},
StartTime: start,
EndTime: end,
UpdatedAt: updated,
Title: sql.NullString{String: "Several Chances for Rain Through Monday", Valid: true},
Description: sql.NullString{String: "Scattered showers and thunderstorms.", Valid: true},
AltText: sql.NullString{String: "Forecast slide.", Valid: true},
Priority: true,
StoryOrder: 1,
DownloadURL: sql.NullString{String: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1", Valid: true},
})
if story.OfficeID != "LSX" {
t.Fatalf("expected office id LSX, got %q", story.OfficeID)
}
if story.Title != "Several Chances for Rain Through Monday" {
t.Fatalf("unexpected title: %q", story.Title)
}
if !story.Priority {
t.Fatalf("expected priority true")
}
if story.Order != 1 {
t.Fatalf("expected order 1, got %d", story.Order)
}
if story.DownloadURL == "" {
t.Fatalf("expected download URL")
}
if story.StartTime.Location().String() != "UTC" || story.EndTime.Location().String() != "UTC" || story.UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected story timestamps to be UTC-normalized, got %s %s %s", story.StartTime, story.EndTime, story.UpdatedAt)
}
}
func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC)
sent2 := sent1.Add(10 * time.Minute)
alerts := []indexedAlert{
{Index: 4, Alert: mapAlertRow(alertRow{AlertIndex: 4, AlertID: "a-4"}).Alert},
{Index: 9, Alert: mapAlertRow(alertRow{AlertIndex: 9, AlertID: "a-9"}).Alert},
}
references := []indexedAlertReference{
mapAlertReferenceRow(alertReferenceRow{AlertIndex: 4, Identifier: sql.NullString{String: "r1", Valid: true}, Sent: sql.NullTime{Time: sent1, Valid: true}}),
mapAlertReferenceRow(alertReferenceRow{AlertIndex: 4, Identifier: sql.NullString{String: "r2", Valid: true}, Sent: sql.NullTime{Time: sent2, Valid: true}}),
mapAlertReferenceRow(alertReferenceRow{AlertIndex: 9, Identifier: sql.NullString{String: "r9", Valid: true}}),
}
out := attachAlertReferences(alerts, references)
if len(out) != 2 {
t.Fatalf("expected 2 alerts, got %d", len(out))
}
if len(out[0].References) != 2 {
t.Fatalf("expected first alert to have 2 references, got %d", len(out[0].References))
}
if out[0].References[0].Identifier != "r1" || out[0].References[1].Identifier != "r2" {
t.Fatalf("unexpected first alert reference order: %+v", out[0].References)
}
if len(out[1].References) != 1 || out[1].References[0].Identifier != "r9" {
t.Fatalf("unexpected second alert references: %+v", out[1].References)
}
}
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 0,
})
if got != nil {
t.Fatalf("expected nil for empty sample window, got %+v", got)
}
}
func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
isDay := true
got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 12,
TemperatureC: sql.NullFloat64{Float64: 15.5, Valid: true},
ApparentTemperatureC: sql.NullFloat64{Float64: 14.2, Valid: true},
DewpointC: sql.NullFloat64{Float64: 10.1, Valid: true},
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
IsDay: sql.NullBool{Bool: isDay, Valid: true},
})
if got == nil {
t.Fatalf("expected mapped current conditions")
}
if got.TemperatureC == nil || *got.TemperatureC != 15.5 {
t.Fatalf("expected temperature pointer 15.5, got %v", got.TemperatureC)
}
if got.ApparentTemperatureC == nil || *got.ApparentTemperatureC != 14.2 {
t.Fatalf("expected apparent temp pointer 14.2, got %v", got.ApparentTemperatureC)
}
if got.DewpointC == nil || *got.DewpointC != 10.1 {
t.Fatalf("expected dewpoint pointer 10.1, got %v", got.DewpointC)
}
if got.RelativeHumidityPercent == nil || *got.RelativeHumidityPercent != 72 {
t.Fatalf("expected rh pointer 72, got %v", got.RelativeHumidityPercent)
}
if got.WindSpeedKmh == nil || *got.WindSpeedKmh != 24.8 {
t.Fatalf("expected wind speed pointer 24.8, got %v", got.WindSpeedKmh)
}
if got.WindDirectionDegrees == nil || *got.WindDirectionDegrees != 182.5 {
t.Fatalf("expected wind direction pointer 182.5, got %v", got.WindDirectionDegrees)
}
if got.ConditionCode != 65 {
t.Fatalf("expected condition code 65, got %d", got.ConditionCode)
}
if got.IsDay == nil || !*got.IsDay {
t.Fatalf("expected isDay pointer true, got %v", got.IsDay)
}
}

View File

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

View File

@@ -0,0 +1,28 @@
// weatherstories_mapper.go maps weather story rows into weather model payloads.
// Layer: adapters/outbound/postgres weather stories feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
func mapWeatherStoryRunParentRow(row weatherStoryRunParentRow) model.WeatherStoryRun {
return model.WeatherStoryRun{
OfficeID: stringValue(row.OfficeID),
AsOf: row.AsOf.UTC(),
Stories: nil,
}
}
func mapWeatherStoryRow(row weatherStoryRow) model.WeatherStory {
return model.WeatherStory{
OfficeID: stringValue(row.OfficeID),
StartTime: row.StartTime.UTC(),
EndTime: row.EndTime.UTC(),
UpdatedAt: row.UpdatedAt.UTC(),
Title: stringValue(row.Title),
Description: stringValue(row.Description),
AltText: stringValue(row.AltText),
Priority: row.Priority,
Order: row.StoryOrder,
DownloadURL: stringValue(row.DownloadURL),
}
}

View File

@@ -0,0 +1,48 @@
// weatherstories_queries.go contains SQL text for weather story reads.
// Layer: adapters/outbound/postgres weather stories feature.
package postgres
const (
queryLatestWeatherStoryRun = `
SELECT
event_id,
office_id,
as_of
FROM weather_story_runs
ORDER BY as_of DESC, event_emitted_at DESC
LIMIT 1`
queryWeatherStoriesForRun = `
SELECT
story_index,
office_id,
start_time,
end_time,
updated_at,
title,
description,
alt_text,
priority,
story_order,
download_url
FROM weather_stories
WHERE run_event_id = $1
ORDER BY story_index ASC`
queryLatestWeatherStory = `
SELECT
story_index,
office_id,
start_time,
end_time,
updated_at,
title,
description,
alt_text,
priority,
story_order,
download_url
FROM weather_stories
ORDER BY updated_at DESC, as_of DESC, story_order ASC, story_index ASC
LIMIT 1`
)

View File

@@ -0,0 +1,103 @@
// weatherstories_read.go executes weather story queries.
// Layer: adapters/outbound/postgres weather stories feature.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row weatherStoryRunParentRow
err := r.db.QueryRowContext(ctx, queryLatestWeatherStoryRun).Scan(
&row.EventID,
&row.OfficeID,
&row.AsOf,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest weather story run: %w", err)
}
run := mapWeatherStoryRunParentRow(row)
stories, err := r.loadWeatherStories(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Stories = stories
return &run, nil
}
func (r *Repository) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("postgres repository is not configured")
}
var row weatherStoryRow
err := r.db.QueryRowContext(ctx, queryLatestWeatherStory).Scan(
&row.StoryIndex,
&row.OfficeID,
&row.StartTime,
&row.EndTime,
&row.UpdatedAt,
&row.Title,
&row.Description,
&row.AltText,
&row.Priority,
&row.StoryOrder,
&row.DownloadURL,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest weather story: %w", err)
}
story := mapWeatherStoryRow(row)
return &story, nil
}
func (r *Repository) loadWeatherStories(ctx context.Context, eventID string) ([]model.WeatherStory, error) {
rows, err := r.db.QueryContext(ctx, queryWeatherStoriesForRun, eventID)
if err != nil {
return nil, fmt.Errorf("query weather stories: %w", err)
}
defer rows.Close()
out := make([]model.WeatherStory, 0)
for rows.Next() {
var row weatherStoryRow
if err := rows.Scan(
&row.StoryIndex,
&row.OfficeID,
&row.StartTime,
&row.EndTime,
&row.UpdatedAt,
&row.Title,
&row.Description,
&row.AltText,
&row.Priority,
&row.StoryOrder,
&row.DownloadURL,
); err != nil {
return nil, fmt.Errorf("scan weather story row: %w", err)
}
out = append(out, mapWeatherStoryRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate weather story rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,28 @@
// weatherstories_rows.go defines row DTOs for weather story reads.
// Layer: adapters/outbound/postgres weather stories feature.
package postgres
import (
"database/sql"
"time"
)
type weatherStoryRunParentRow struct {
EventID string
OfficeID sql.NullString
AsOf time.Time
}
type weatherStoryRow struct {
StoryIndex int
OfficeID sql.NullString
StartTime time.Time
EndTime time.Time
UpdatedAt time.Time
Title sql.NullString
Description sql.NullString
AltText sql.NullString
Priority bool
StoryOrder int
DownloadURL sql.NullString
}

View File

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

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

@@ -1,184 +0,0 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"github.com/jackc/pgx/v5/pgxpool"
)
type ObservationRepository struct {
pool *pgxpool.Pool
}
func NewObservationRepository(pool *pgxpool.Pool) *ObservationRepository {
return &ObservationRepository{pool: pool}
}
func (r *ObservationRepository) GetCurrentConditionsSummary(ctx context.Context, window time.Duration) (ports.ObservationCurrentConditionsMetric, error) {
var temperature sql.NullFloat64
var apparent sql.NullFloat64
var dewpoint sql.NullFloat64
var relativeHumidity sql.NullFloat64
var windSpeed sql.NullFloat64
var windDirection sql.NullFloat64
var conditionCode sql.NullInt64
var isDay sql.NullBool
if err := r.pool.QueryRow(ctx, queryCurrentConditionsSummary, windowMinutes(window)).Scan(
&temperature,
&apparent,
&dewpoint,
&relativeHumidity,
&windSpeed,
&windDirection,
&conditionCode,
&isDay,
); err != nil {
return ports.ObservationCurrentConditionsMetric{}, fmt.Errorf("query current conditions summary: %w", err)
}
return ports.ObservationCurrentConditionsMetric{
TemperatureC: ptrFloat64(temperature),
ApparentTemperatureC: ptrFloat64(apparent),
DewpointC: ptrFloat64(dewpoint),
RelativeHumidity: ptrFloat64(relativeHumidity),
WindSpeedKmh: ptrFloat64(windSpeed),
WindDirectionDegrees: ptrFloat64(windDirection),
ConditionCode: ptrInt(conditionCode),
IsDay: ptrBool(isDay),
}, nil
}
func (r *ObservationRepository) ListRecentObservations(ctx context.Context, count int) ([]ports.ObservationRecordMetric, error) {
rows, err := r.pool.Query(ctx, queryRecentObservations, count)
if err != nil {
return nil, fmt.Errorf("query recent observations: %w", err)
}
defer rows.Close()
out := make([]ports.ObservationRecordMetric, 0, count)
eventIDs := make([]string, 0, count)
for rows.Next() {
var eventID string
var stationID sql.NullString
var stationName sql.NullString
var timestamp time.Time
var conditionCode int
var isDay sql.NullBool
var textDescription sql.NullString
var temperature sql.NullFloat64
var dewpoint sql.NullFloat64
var windDirection sql.NullFloat64
var windSpeed sql.NullFloat64
var windGust sql.NullFloat64
var pressure sql.NullFloat64
var visibility sql.NullFloat64
var relativeHumidity sql.NullFloat64
var apparent sql.NullFloat64
if err := rows.Scan(
&eventID,
&stationID,
&stationName,
&timestamp,
&conditionCode,
&isDay,
&textDescription,
&temperature,
&dewpoint,
&windDirection,
&windSpeed,
&windGust,
&pressure,
&visibility,
&relativeHumidity,
&apparent,
); err != nil {
return nil, fmt.Errorf("scan recent observation row: %w", err)
}
eventIDs = append(eventIDs, eventID)
out = append(out, ports.ObservationRecordMetric{
EventID: eventID,
StationID: ptrString(stationID),
StationName: ptrString(stationName),
Timestamp: timestamp,
ConditionCode: conditionCode,
IsDay: ptrBool(isDay),
TextDescription: ptrString(textDescription),
TemperatureC: ptrFloat64(temperature),
DewpointC: ptrFloat64(dewpoint),
WindDirectionDegrees: ptrFloat64(windDirection),
WindSpeedKmh: ptrFloat64(windSpeed),
WindGustKmh: ptrFloat64(windGust),
BarometricPressurePa: ptrFloat64(pressure),
VisibilityMeters: ptrFloat64(visibility),
RelativeHumidityPercent: ptrFloat64(relativeHumidity),
ApparentTemperatureC: ptrFloat64(apparent),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate recent observations rows: %w", err)
}
if len(out) == 0 {
return out, nil
}
presentWeatherByEventID, err := r.listObservationPresentWeather(ctx, eventIDs)
if err != nil {
return nil, err
}
for i := range out {
out[i].PresentWeather = presentWeatherByEventID[out[i].EventID]
}
return out, nil
}
func (r *ObservationRepository) listObservationPresentWeather(ctx context.Context, eventIDs []string) (map[string][]ports.ObservationPresentWeatherMetric, error) {
rows, err := r.pool.Query(ctx, queryObservationPresentWeather, eventIDs)
if err != nil {
return nil, fmt.Errorf("query observation present weather: %w", err)
}
defer rows.Close()
out := make(map[string][]ports.ObservationPresentWeatherMetric, len(eventIDs))
for rows.Next() {
var eventID string
var weatherIndex int
var rawText sql.NullString
if err := rows.Scan(&eventID, &weatherIndex, &rawText); err != nil {
return nil, fmt.Errorf("scan observation present weather row: %w", err)
}
var raw map[string]any
if rawText.Valid {
if err := json.Unmarshal([]byte(rawText.String), &raw); err != nil {
return nil, fmt.Errorf("decode observation present weather raw payload: %w", err)
}
}
out[eventID] = append(out[eventID], ports.ObservationPresentWeatherMetric{
Raw: raw,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation present weather rows: %w", err)
}
return out, nil
}
func windowMinutes(window time.Duration) int {
return int(window / time.Minute)
}

View File

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

View File

@@ -1,48 +0,0 @@
package postgres
import (
"strings"
"testing"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
func TestQueriesUseParametersNotHardcodedValues(t *testing.T) {
if !strings.Contains(queryCurrentConditionsSummary, "make_interval(mins => $1)") {
t.Fatalf("current conditions summary query must use $1 minutes parameter")
}
if strings.Contains(queryCurrentConditionsSummary, "30 minutes") {
t.Fatalf("current conditions summary query should not hardcode 30 minutes")
}
if !strings.Contains(queryCurrentConditionsSummary, "MAX(condition_code)") {
t.Fatalf("current conditions summary query should compute max condition code")
}
if !strings.Contains(queryCurrentConditionsSummary, "ORDER BY observed_at DESC") || !strings.Contains(queryCurrentConditionsSummary, "LIMIT 1") {
t.Fatalf("current conditions summary query should select latest is_day")
}
if !strings.Contains(queryRecentObservations, "LIMIT $1") {
t.Fatalf("recent observations query must use parameterized limit")
}
if strings.Contains(queryRecentObservations, "LIMIT 5") {
t.Fatalf("recent observations query should not hardcode limit=5")
}
if !strings.Contains(queryObservationPresentWeather, "event_id = ANY($1)") {
t.Fatalf("observation present weather query must use parameterized event list")
}
if !strings.Contains(queryForecastPeriodsAt, "LIMIT $2") {
t.Fatalf("forecast query must use parameterized limit")
}
if strings.Contains(queryForecastPeriodsAt, "LIMIT 5") {
t.Fatalf("forecast query should not hardcode limit=5")
}
}
func TestWindowMinutesUsesObservationWindowConstant(t *testing.T) {
got := windowMinutes(constants.ObservationWindow)
if got != 30 {
t.Fatalf("expected 30 minutes from constants.ObservationWindow, got %d", got)
}
}

View File

@@ -1,46 +0,0 @@
package postgres
import (
"database/sql"
"time"
)
func ptrFloat64(v sql.NullFloat64) *float64 {
if !v.Valid {
return nil
}
f := v.Float64
return &f
}
func ptrString(v sql.NullString) *string {
if !v.Valid {
return nil
}
s := v.String
return &s
}
func ptrBool(v sql.NullBool) *bool {
if !v.Valid {
return nil
}
b := v.Bool
return &b
}
func ptrInt(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
i := int(v.Int64)
return &i
}
func ptrTime(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time
return &t
}

View File

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

View File

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

255
internal/app/service.go Normal file
View File

@@ -0,0 +1,255 @@
// service.go defines application read ports and use-case orchestration.
// Layer: internal/app core business-facing API.
package app
import (
"context"
"strings"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Repository defines outbound data access used by weatherapi use cases.
type Repository interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error)
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
LatestConvectiveOutlookRun(ctx context.Context) (*model.WeatherOutlookRun, error)
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error)
}
// OutlookFilter selects outlook entries from the latest convective outlook run.
type OutlookFilter struct {
Day *int
OutlookType string
ActiveAt *time.Time
}
// Service provides weather read use-cases.
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
return s.repo.LatestObservation(ctx)
}
func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return s.repo.LatestHourlyForecast(ctx)
}
func (s *Service) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return s.repo.LatestNarrativeForecast(ctx)
}
func (s *Service) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) {
return s.repo.LatestForecastDiscussion(ctx)
}
func (s *Service) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) {
return s.repo.LatestWeatherStoryRun(ctx)
}
func (s *Service) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) {
return s.repo.LatestWeatherStory(ctx)
}
func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
return s.repo.LatestAlertRun(ctx)
}
func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
run, err := s.repo.LatestAlertRun(ctx)
if err != nil {
return nil, err
}
if run == nil {
return nil, nil
}
out := cloneAlertRun(run)
alerts := out.Alerts[:0]
for _, alert := range out.Alerts {
if isActiveAlert(alert, activeAt) {
alerts = append(alerts, alert)
}
}
out.Alerts = alerts
return out, nil
}
func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) {
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
if err != nil {
return nil, err
}
if run == nil {
return nil, nil
}
out := cloneOutlookRun(run)
outlooks := out.Outlooks[:0]
for _, outlook := range out.Outlooks {
if matchesOutlookFilter(outlook, filter) {
outlooks = append(outlooks, outlook)
}
}
out.Outlooks = outlooks
out.Discussions = filterOutlookDiscussions(out.Discussions, out.Outlooks)
return out, nil
}
func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) {
return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault)
}
func matchesOutlookFilter(outlook model.WeatherOutlook, filter OutlookFilter) bool {
if filter.Day != nil && outlook.Day != *filter.Day {
return false
}
if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) {
return false
}
if filter.ActiveAt != nil && (filter.ActiveAt.Before(outlook.ValidFrom) || !filter.ActiveAt.Before(outlook.ValidTo)) {
return false
}
return true
}
func normalizeOutlookType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func cloneAlertRun(run *model.WeatherAlertRun) *model.WeatherAlertRun {
out := *run
out.Latitude = copyFloat64(run.Latitude)
out.Longitude = copyFloat64(run.Longitude)
if run.Alerts != nil {
out.Alerts = make([]model.WeatherAlert, len(run.Alerts))
for i := range run.Alerts {
out.Alerts[i] = cloneAlert(run.Alerts[i])
}
}
return &out
}
func cloneAlert(alert model.WeatherAlert) model.WeatherAlert {
out := alert
out.Sent = copyTime(alert.Sent)
out.Effective = copyTime(alert.Effective)
out.Onset = copyTime(alert.Onset)
out.Expires = copyTime(alert.Expires)
if alert.References != nil {
out.References = make([]model.AlertReference, len(alert.References))
for i := range alert.References {
out.References[i] = cloneAlertReference(alert.References[i])
}
}
return out
}
func cloneAlertReference(ref model.AlertReference) model.AlertReference {
out := ref
out.Sent = copyTime(ref.Sent)
return out
}
func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
if strings.EqualFold(strings.TrimSpace(alert.MessageType), "Cancel") {
return false
}
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
return false
}
if alert.Expires != nil && !activeAt.Before(*alert.Expires) {
return false
}
return true
}
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
out := *run
out.Latitude = copyFloat64(run.Latitude)
out.Longitude = copyFloat64(run.Longitude)
out.IssuedAt = copyTime(run.IssuedAt)
if run.Outlooks != nil {
out.Outlooks = make([]model.WeatherOutlook, len(run.Outlooks))
for i := range run.Outlooks {
out.Outlooks[i] = cloneOutlook(run.Outlooks[i])
}
}
if run.Discussions != nil {
out.Discussions = make([]model.WeatherOutlookDiscussion, len(run.Discussions))
for i := range run.Discussions {
out.Discussions[i] = cloneOutlookDiscussion(run.Discussions[i])
}
}
return &out
}
func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook {
out := outlook
out.SeverityRank = copyInt(outlook.SeverityRank)
if outlook.Geometry != nil {
out.Geometry = append([]byte(nil), outlook.Geometry...)
}
return out
}
func cloneOutlookDiscussion(discussion model.WeatherOutlookDiscussion) model.WeatherOutlookDiscussion {
out := discussion
out.UpdatedAt = copyTime(discussion.UpdatedAt)
return out
}
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion {
if len(outlooks) == 0 {
return []model.WeatherOutlookDiscussion{}
}
retainedDays := make(map[int]struct{}, len(outlooks))
for _, outlook := range outlooks {
retainedDays[outlook.Day] = struct{}{}
}
out := discussions[:0]
for _, discussion := range discussions {
if _, ok := retainedDays[discussion.Day]; ok {
out = append(out, discussion)
}
}
return out
}
func copyFloat64(value *float64) *float64 {
if value == nil {
return nil
}
out := *value
return &out
}
func copyInt(value *int) *int {
if value == nil {
return nil
}
out := *value
return &out
}
func copyTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
out := *value
return &out
}

View File

@@ -0,0 +1,676 @@
// service_test.go validates application service delegation behavior.
// Layer: internal/app tests for read use-case orchestration.
package app
import (
"context"
"errors"
"strconv"
"testing"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type fakeRepository struct {
observation *model.WeatherObservation
forecast *model.WeatherForecastRun
narrative *model.WeatherForecastRun
discussion *model.WeatherForecastDiscussion
storyRun *model.WeatherStoryRun
story *model.WeatherStory
alerts *model.WeatherAlertRun
outlookRun *model.WeatherOutlookRun
conditions *CurrentConditions
err error
currentConditionsWindow int
alertRunCalls int
outlookRunCalls int
}
func (r *fakeRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
return r.observation, r.err
}
func (r *fakeRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
return r.forecast, r.err
}
func (r *fakeRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
return r.narrative, r.err
}
func (r *fakeRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
return r.discussion, r.err
}
func (r *fakeRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
return r.storyRun, r.err
}
func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
return r.story, r.err
}
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
r.alertRunCalls++
return r.alerts, r.err
}
func (r *fakeRepository) LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error) {
r.outlookRunCalls++
return r.outlookRun, r.err
}
func (r *fakeRepository) CurrentConditions(_ context.Context, observationWindowMinutes int) (*CurrentConditions, error) {
r.currentConditionsWindow = observationWindowMinutes
return r.conditions, r.err
}
func TestServiceDelegatesObservation(t *testing.T) {
repo := &fakeRepository{observation: &model.WeatherObservation{StationID: "KSTL"}}
svc := NewService(repo)
obs, err := svc.LatestObservation(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if obs == nil || obs.StationID != "KSTL" {
t.Fatalf("unexpected observation: %+v", obs)
}
}
func TestServiceDelegatesForecast(t *testing.T) {
repo := &fakeRepository{forecast: &model.WeatherForecastRun{LocationID: "stl"}}
svc := NewService(repo)
run, err := svc.LatestHourlyForecast(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected forecast: %+v", run)
}
}
func TestServiceDelegatesNarrativeForecast(t *testing.T) {
repo := &fakeRepository{narrative: &model.WeatherForecastRun{LocationID: "stl-narrative"}}
svc := NewService(repo)
run, err := svc.LatestNarrativeForecast(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl-narrative" {
t.Fatalf("unexpected forecast: %+v", run)
}
}
func TestServiceDelegatesAlerts(t *testing.T) {
repo := &fakeRepository{alerts: &model.WeatherAlertRun{LocationID: "stl"}}
svc := NewService(repo)
run, err := svc.LatestAlertRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected alert run: %+v", run)
}
}
func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) {
activeAt := testTime(12)
repo := &fakeRepository{alerts: testAlertRun()}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.alertRunCalls != 1 {
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
}
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset"})
}
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
repo := &fakeRepository{}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run != nil {
t.Fatalf("expected nil alert run, got %+v", run)
}
}
func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) {
want := errors.New("alert read failed")
repo := &fakeRepository{err: want}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
if !errors.Is(err, want) {
t.Fatalf("expected error %v, got %v", want, err)
}
if run != nil {
t.Fatalf("expected nil alert run on error, got %+v", run)
}
}
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
activeAt := testTime(12)
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14)),
})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil {
t.Fatal("expected alert run")
}
if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(10)) {
t.Fatalf("unexpected run metadata: %+v", run)
}
if run.Latitude == nil || *run.Latitude != 38.62 {
t.Fatalf("unexpected latitude: %v", run.Latitude)
}
if run.Longitude == nil || *run.Longitude != -90.2 {
t.Fatalf("unexpected longitude: %v", run.Longitude)
}
if run.Alerts == nil {
t.Fatal("expected empty alert slice, got nil")
}
if len(run.Alerts) != 0 {
t.Fatalf("expected no alerts, got %+v", run.Alerts)
}
}
func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
original := testAlertRun()
repo := &fakeRepository{alerts: original}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(run.Alerts) == 0 {
t.Fatal("expected active alerts")
}
*run.Latitude = 99
*run.Longitude = -99
*run.Alerts[0].Sent = testTime(1)
*run.Alerts[0].Effective = testTime(2)
*run.Alerts[0].Onset = testTime(3)
*run.Alerts[0].Expires = testTime(4)
*run.Alerts[0].References[0].Sent = testTime(5)
run.Alerts[0].ID = "changed"
run.Alerts[0].References[0].ID = "changed"
run.Alerts = run.Alerts[:1]
if *original.Latitude != 38.62 {
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
}
if *original.Longitude != -90.2 {
t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude)
}
if original.Alerts[0].ID != "current" {
t.Fatalf("expected original alert ID unchanged, got %q", original.Alerts[0].ID)
}
if original.Alerts[0].Sent == nil || !original.Alerts[0].Sent.Equal(testTime(9)) {
t.Fatalf("expected original sent unchanged, got %v", original.Alerts[0].Sent)
}
if original.Alerts[0].Effective == nil || !original.Alerts[0].Effective.Equal(testTime(10)) {
t.Fatalf("expected original effective unchanged, got %v", original.Alerts[0].Effective)
}
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
}
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(13)) {
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
}
if original.Alerts[0].References[0].ID != "ref-current" {
t.Fatalf("expected original reference ID unchanged, got %q", original.Alerts[0].References[0].ID)
}
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
}
if len(original.Alerts) != 8 {
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
}
}
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.outlookRunCalls != 1 {
t.Fatalf("expected one repository call, got %d", repo.outlookRunCalls)
}
if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected outlook run: %+v", run)
}
assertDiscussionDays(t, run, []int{1, 2})
}
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
repo := &fakeRepository{}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run != nil {
t.Fatalf("expected nil outlook run, got %+v", run)
}
}
func TestServiceDelegatesForecastDiscussion(t *testing.T) {
repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}}
svc := NewService(repo)
run, err := svc.LatestForecastDiscussion(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.OfficeID != "LSX" {
t.Fatalf("unexpected forecast discussion: %+v", run)
}
}
func TestServiceDelegatesWeatherStoryRun(t *testing.T) {
repo := &fakeRepository{storyRun: &model.WeatherStoryRun{OfficeID: "LSX"}}
svc := NewService(repo)
run, err := svc.LatestWeatherStoryRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil || run.OfficeID != "LSX" {
t.Fatalf("unexpected weather story run: %+v", run)
}
}
func TestServiceDelegatesWeatherStory(t *testing.T) {
repo := &fakeRepository{story: &model.WeatherStory{OfficeID: "LSX", Title: "Rain chances"}}
svc := NewService(repo)
story, err := svc.LatestWeatherStory(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if story == nil || story.Title != "Rain chances" {
t.Fatalf("unexpected weather story: %+v", story)
}
}
func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) {
repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}}
svc := NewService(repo)
_, err := svc.CurrentConditions(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.currentConditionsWindow != ObservationWindowMinutesDefault {
t.Fatalf("expected observation window %d, got %d", ObservationWindowMinutesDefault, repo.currentConditionsWindow)
}
}
func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) {
day := 2
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{Day: &day})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"day-2"})
assertDiscussionDays(t, run, []int{2})
}
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{OutlookType: " Tornado "})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"tor-1"})
assertDiscussionDays(t, run, []int{1})
}
func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &activeAt})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
assertDiscussionDays(t, run, []int{1})
}
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
day := 1
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{
Day: &day,
OutlookType: "categorical",
ActiveAt: &activeAt,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"cat-1"})
assertDiscussionDays(t, run, []int{1})
}
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
run := testOutlookRun()
run.Outlooks = run.Outlooks[:1]
validFrom := run.Outlooks[0].ValidFrom
validTo := run.Outlooks[0].ValidTo
repo := &fakeRepository{outlookRun: run}
svc := NewService(repo)
fromRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validFrom})
if err != nil {
t.Fatalf("unexpected validFrom error: %v", err)
}
assertOutlookIDs(t, fromRun, []string{"cat-1"})
assertDiscussionDays(t, fromRun, []int{1})
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
if err != nil {
t.Fatalf("unexpected validTo error: %v", err)
}
assertOutlookIDs(t, toRun, nil)
assertDiscussionDays(t, toRun, nil)
}
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
day := 3
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{Day: &day})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil {
t.Fatal("expected outlook run")
}
if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(12)) {
t.Fatalf("unexpected run metadata: %+v", run)
}
if run.Outlooks == nil {
t.Fatal("expected empty outlook slice, got nil")
}
if len(run.Outlooks) != 0 {
t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
}
if run.Discussions == nil {
t.Fatal("expected empty discussions slice, got nil")
}
if len(run.Discussions) != 0 {
t.Fatalf("expected no discussions, got %+v", run.Discussions)
}
}
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
original := testOutlookRun()
repo := &fakeRepository{outlookRun: original}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(run.Outlooks) == 0 {
t.Fatal("expected outlooks")
}
*run.Latitude = 99
*run.Longitude = -99
*run.IssuedAt = testTime(99)
*run.Outlooks[0].SeverityRank = 99
*run.Discussions[0].UpdatedAt = testTime(98)
run.Outlooks[0].Geometry[0] = '{'
run.Outlooks[0].ID = "changed"
run.Discussions[0].Headline = "changed"
run.Outlooks = run.Outlooks[:1]
run.Discussions = run.Discussions[:1]
if *original.Latitude != 38.62 {
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
}
if *original.Longitude != -90.2 {
t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude)
}
if !original.IssuedAt.Equal(testTime(11)) {
t.Fatalf("expected original issuedAt unchanged, got %v", original.IssuedAt)
}
if *original.Outlooks[0].SeverityRank != 5 {
t.Fatalf("expected original severity rank unchanged, got %v", *original.Outlooks[0].SeverityRank)
}
if string(original.Outlooks[0].Geometry) != `["cat"]` {
t.Fatalf("expected original geometry unchanged, got %s", original.Outlooks[0].Geometry)
}
if original.Outlooks[0].ID != "cat-1" {
t.Fatalf("expected original outlook ID unchanged, got %q", original.Outlooks[0].ID)
}
if len(original.Outlooks) != 3 {
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
}
if original.Discussions[0].UpdatedAt == nil || !original.Discussions[0].UpdatedAt.Equal(testTime(10)) {
t.Fatalf("expected original discussion updatedAt unchanged, got %v", original.Discussions[0].UpdatedAt)
}
if original.Discussions[0].Headline != "Day 1 headline" {
t.Fatalf("expected original discussion headline unchanged, got %q", original.Discussions[0].Headline)
}
if len(original.Discussions) != 3 {
t.Fatalf("expected original discussion slice unchanged, got %d entries", len(original.Discussions))
}
}
func TestServicePropagatesErrors(t *testing.T) {
want := errors.New("boom")
repo := &fakeRepository{err: want}
svc := NewService(repo)
if _, err := svc.LatestObservation(context.Background()); !errors.Is(err, want) {
t.Fatalf("expected error %v, got %v", want, err)
}
}
func testAlertRun() *model.WeatherAlertRun {
return testAlertRunWithAlerts([]model.WeatherAlert{
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15)),
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14)),
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14)),
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil),
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14)),
})
}
func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun {
latitude := 38.62
longitude := -90.2
return &model.WeatherAlertRun{
LocationID: "stl",
LocationName: "St. Louis",
AsOf: testTime(10),
Latitude: &latitude,
Longitude: &longitude,
Alerts: alerts,
}
}
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, expires *time.Time) model.WeatherAlert {
refSent := testTime(8)
return model.WeatherAlert{
ID: id,
Event: "Thunderstorm Warning",
Headline: "Storm headline",
Severity: "Severe",
Urgency: "Immediate",
Certainty: "Likely",
Status: "Actual",
MessageType: messageType,
Category: "Met",
Response: "Shelter",
Description: "Storm description",
Instruction: "Take shelter",
Sent: sent,
Effective: effective,
Onset: onset,
Expires: expires,
AreaDescription: "St. Louis City",
SenderName: "NWS St. Louis",
References: []model.AlertReference{{
ID: "ref-" + id,
Identifier: "identifier-" + id,
Sender: "sender-" + id,
Sent: &refSent,
}},
}
}
func testTimePtr(hour int) *time.Time {
value := testTime(hour)
return &value
}
func testOutlookRun() *model.WeatherOutlookRun {
latitude := 38.62
longitude := -90.2
issuedAt := testTime(11)
return &model.WeatherOutlookRun{
LocationID: "stl",
LocationName: "St. Louis",
Latitude: &latitude,
Longitude: &longitude,
AsOf: testTime(12),
IssuedAt: &issuedAt,
Outlooks: []model.WeatherOutlook{
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
testOutlook("day-2", 2, "wind", true, testTime(18), testTime(24), 2, `["wind"]`),
},
Discussions: []model.WeatherOutlookDiscussion{
testOutlookDiscussion(1),
testOutlookDiscussion(2),
testOutlookDiscussion(3),
},
}
}
func testOutlookDiscussion(day int) model.WeatherOutlookDiscussion {
updatedAt := testTime(9 + day)
dayText := strconv.Itoa(day)
return model.WeatherOutlookDiscussion{
Day: day,
Headline: "Day " + dayText + " headline",
Summary: "Day " + dayText + " summary",
Discussion: "Day " + dayText + " discussion",
UpdatedAt: &updatedAt,
}
}
func testOutlook(id string, day int, outlookType string, containsLocation bool, validFrom time.Time, validTo time.Time, severityRank int, geometry string) model.WeatherOutlook {
return model.WeatherOutlook{
ID: id,
Provider: "spc",
Product: "convective",
Day: day,
OutlookType: outlookType,
Label: "SLGT",
LabelText: "Slight Risk",
SeverityRank: &severityRank,
ValidFrom: validFrom,
ValidTo: validTo,
IssuedAt: validFrom.Add(-time.Hour),
ExpiresAt: validTo,
Forecaster: "DIAL",
SourceURL: "https://example.test/" + id,
ImageURL: "https://example.test/" + id + ".png",
ContainsLocation: containsLocation,
Geometry: []byte(geometry),
}
}
func testTime(hour int) time.Time {
return time.Date(2026, 6, 11, hour, 0, 0, 0, time.UTC)
}
func assertOutlookIDs(t *testing.T, run *model.WeatherOutlookRun, want []string) {
t.Helper()
if run == nil {
t.Fatal("expected outlook run")
}
if len(run.Outlooks) != len(want) {
t.Fatalf("expected outlook IDs %v, got %+v", want, run.Outlooks)
}
for i := range want {
if run.Outlooks[i].ID != want[i] {
t.Fatalf("expected outlook IDs %v, got %+v", want, run.Outlooks)
}
}
}
func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int) {
t.Helper()
if run == nil {
t.Fatal("expected outlook run")
}
if len(run.Discussions) != len(want) {
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
}
for i := range want {
if run.Discussions[i].Day != want[i] {
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
}
}
}
func assertAlertIDs(t *testing.T, run *model.WeatherAlertRun, want []string) {
t.Helper()
if run == nil {
t.Fatal("expected alert run")
}
if len(run.Alerts) != len(want) {
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
}
for i := range want {
if run.Alerts[i].ID != want[i] {
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
}
}
}

View File

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

View File

@@ -1,82 +0,0 @@
package conditions
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
)
type Service struct {
repo ports.ObservationRepository
registry *units.Registry
window time.Duration
}
func NewService(repo ports.ObservationRepository, registry *units.Registry, window time.Duration) *Service {
return &Service{repo: repo, registry: registry, window: window}
}
type Response struct {
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidity *float64 `json:"relativeHumidityPercent,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
ConditionText *string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
}
func (s *Service) GetCurrent(ctx context.Context, unitSystem string) (Response, error) {
if s == nil {
return Response{}, fmt.Errorf("conditions service is nil")
}
converter, err := s.registry.Resolve(unitSystem)
if err != nil {
return Response{}, err
}
summary, err := s.repo.GetCurrentConditionsSummary(ctx, s.window)
if err != nil {
return Response{}, err
}
resp := Response{
RelativeHumidity: summary.RelativeHumidity,
WindDirectionDegrees: summary.WindDirectionDegrees,
IsDay: summary.IsDay,
}
if summary.ConditionCode != nil {
resp.ConditionText = ptrString(standards.WMOText(model.WMOCode(*summary.ConditionCode), summary.IsDay))
}
switch converter.System() {
case constants.UnitSystemUS:
resp.TemperatureF = converter.TemperatureCToOutput(summary.TemperatureC)
resp.ApparentTemperatureF = converter.TemperatureCToOutput(summary.ApparentTemperatureC)
resp.DewpointF = converter.TemperatureCToOutput(summary.DewpointC)
resp.WindSpeedMph = converter.SpeedKmhToOutput(summary.WindSpeedKmh)
default:
resp.TemperatureC = converter.TemperatureCToOutput(summary.TemperatureC)
resp.ApparentTemperatureC = converter.TemperatureCToOutput(summary.ApparentTemperatureC)
resp.DewpointC = converter.TemperatureCToOutput(summary.DewpointC)
resp.WindSpeedKmh = converter.SpeedKmhToOutput(summary.WindSpeedKmh)
}
return resp, nil
}
func ptrString(v string) *string {
return &v
}

View File

@@ -1,131 +0,0 @@
package forecasts
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
type Service struct {
repo ports.ForecastRepository
registry *units.Registry
limit int
}
func NewService(repo ports.ForecastRepository, registry *units.Registry, limit int) *Service {
return &Service{repo: repo, registry: registry, limit: limit}
}
type Response struct {
Timestamp time.Time `json:"timestamp"`
Periods []PeriodResponse `json:"periods"`
}
type PeriodResponse struct {
PeriodIndex int `json:"periodIndex"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Name *string `json:"name,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
ConditionCode int `json:"conditionCode"`
ConditionText *string `json:"conditionText,omitempty"`
ProviderRawDescription *string `json:"providerRawDescription,omitempty"`
TextDescription *string `json:"textDescription,omitempty"`
DetailedText *string `json:"detailedText,omitempty"`
IconURL *string `json:"iconUrl,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
TemperatureCMin *float64 `json:"temperatureCMin,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty"`
TemperatureCMax *float64 `json:"temperatureCMax,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"`
SnowfallDepthMm *float64 `json:"snowfallDepthMm,omitempty"`
UVIndex *float64 `json:"uvIndex,omitempty"`
}
func (s *Service) GetByTimestamp(ctx context.Context, ts time.Time, unitSystem string) (Response, error) {
if s == nil {
return Response{}, fmt.Errorf("forecasts service is nil")
}
converter, err := s.registry.Resolve(unitSystem)
if err != nil {
return Response{}, err
}
periodsMetric, err := s.repo.ListForecastPeriodsAt(ctx, ts, s.limit)
if err != nil {
return Response{}, err
}
periods := make([]PeriodResponse, 0, len(periodsMetric))
for _, p := range periodsMetric {
item := PeriodResponse{
PeriodIndex: p.PeriodIndex,
StartTime: p.StartTime,
EndTime: p.EndTime,
Name: p.Name,
IsDay: p.IsDay,
ConditionCode: p.ConditionCode,
ConditionText: p.ConditionText,
ProviderRawDescription: p.ProviderRawDescription,
TextDescription: p.TextDescription,
DetailedText: p.DetailedText,
IconURL: p.IconURL,
RelativeHumidityPercent: p.RelativeHumidityPercent,
WindDirectionDegrees: p.WindDirectionDegrees,
BarometricPressurePa: p.BarometricPressurePa,
VisibilityMeters: p.VisibilityMeters,
CloudCoverPercent: p.CloudCoverPercent,
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitationPercent,
PrecipitationAmountMm: p.PrecipitationAmountMm,
SnowfallDepthMm: p.SnowfallDepthMm,
UVIndex: p.UVIndex,
}
switch converter.System() {
case constants.UnitSystemUS:
item.TemperatureF = converter.TemperatureCToOutput(p.TemperatureC)
item.TemperatureFMin = converter.TemperatureCToOutput(p.TemperatureCMin)
item.TemperatureFMax = converter.TemperatureCToOutput(p.TemperatureCMax)
item.DewpointF = converter.TemperatureCToOutput(p.DewpointC)
item.WindSpeedMph = converter.SpeedKmhToOutput(p.WindSpeedKmh)
item.WindGustMph = converter.SpeedKmhToOutput(p.WindGustKmh)
item.ApparentTemperatureF = converter.TemperatureCToOutput(p.ApparentTemperatureC)
default:
item.TemperatureC = converter.TemperatureCToOutput(p.TemperatureC)
item.TemperatureCMin = converter.TemperatureCToOutput(p.TemperatureCMin)
item.TemperatureCMax = converter.TemperatureCToOutput(p.TemperatureCMax)
item.DewpointC = converter.TemperatureCToOutput(p.DewpointC)
item.WindSpeedKmh = converter.SpeedKmhToOutput(p.WindSpeedKmh)
item.WindGustKmh = converter.SpeedKmhToOutput(p.WindGustKmh)
item.ApparentTemperatureC = converter.TemperatureCToOutput(p.ApparentTemperatureC)
}
periods = append(periods, item)
}
return Response{
Timestamp: ts,
Periods: periods,
}, nil
}

View File

@@ -1,109 +0,0 @@
package observations
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/units"
"gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
type Service struct {
repo ports.ObservationRepository
registry *units.Registry
}
func NewService(repo ports.ObservationRepository, registry *units.Registry) *Service {
return &Service{repo: repo, registry: registry}
}
type Response struct {
Observations []ObservationResponse `json:"observations"`
}
type ObservationResponse struct {
StationID *string `json:"stationId,omitempty"`
StationName *string `json:"stationName,omitempty"`
Timestamp time.Time `json:"timestamp"`
ConditionCode int `json:"conditionCode"`
IsDay *bool `json:"isDay,omitempty"`
TextDescription *string `json:"textDescription,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
PresentWeather []PresentWeatherResponse `json:"presentWeather,omitempty"`
}
type PresentWeatherResponse struct {
Raw map[string]any `json:"raw,omitempty"`
}
func (s *Service) GetRecent(ctx context.Context, count int, unitSystem string) (Response, error) {
if s == nil {
return Response{}, fmt.Errorf("observations service is nil")
}
converter, err := s.registry.Resolve(unitSystem)
if err != nil {
return Response{}, err
}
records, err := s.repo.ListRecentObservations(ctx, count)
if err != nil {
return Response{}, err
}
out := make([]ObservationResponse, 0, len(records))
for _, r := range records {
item := ObservationResponse{
StationID: r.StationID,
StationName: r.StationName,
Timestamp: r.Timestamp,
ConditionCode: r.ConditionCode,
IsDay: r.IsDay,
TextDescription: r.TextDescription,
WindDirectionDegrees: r.WindDirectionDegrees,
BarometricPressurePa: r.BarometricPressurePa,
VisibilityMeters: r.VisibilityMeters,
RelativeHumidityPercent: r.RelativeHumidityPercent,
}
presentWeather := make([]PresentWeatherResponse, 0, len(r.PresentWeather))
for _, pw := range r.PresentWeather {
presentWeather = append(presentWeather, PresentWeatherResponse{Raw: pw.Raw})
}
item.PresentWeather = presentWeather
switch converter.System() {
case constants.UnitSystemUS:
item.TemperatureF = converter.TemperatureCToOutput(r.TemperatureC)
item.DewpointF = converter.TemperatureCToOutput(r.DewpointC)
item.WindSpeedMph = converter.SpeedKmhToOutput(r.WindSpeedKmh)
item.WindGustMph = converter.SpeedKmhToOutput(r.WindGustKmh)
item.ApparentTemperatureF = converter.TemperatureCToOutput(r.ApparentTemperatureC)
default:
item.TemperatureC = converter.TemperatureCToOutput(r.TemperatureC)
item.DewpointC = converter.TemperatureCToOutput(r.DewpointC)
item.WindSpeedKmh = converter.SpeedKmhToOutput(r.WindSpeedKmh)
item.WindGustKmh = converter.SpeedKmhToOutput(r.WindGustKmh)
item.ApparentTemperatureC = converter.TemperatureCToOutput(r.ApparentTemperatureC)
}
out = append(out, item)
}
return Response{Observations: out}, nil
}

View File

@@ -1,62 +0,0 @@
package units
import (
"math"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
)
type Converter interface {
TemperatureCToOutput(celsius *float64) *float64
SpeedKmhToOutput(kmh *float64) *float64
System() string
}
type USConverter struct{}
func (USConverter) System() string {
return "us"
}
func (USConverter) TemperatureCToOutput(celsius *float64) *float64 {
if celsius == nil {
return nil
}
f := round(constants.CelsiusToFahrenheit(*celsius), constants.TempFahrenheitPrecision)
return &f
}
func (USConverter) SpeedKmhToOutput(kmh *float64) *float64 {
if kmh == nil {
return nil
}
mph := round(constants.KmhToMph(*kmh), constants.WindMphPrecision)
return &mph
}
type MetricConverter struct{}
func (MetricConverter) System() string {
return "metric"
}
func (MetricConverter) TemperatureCToOutput(celsius *float64) *float64 {
return clone(celsius)
}
func (MetricConverter) SpeedKmhToOutput(kmh *float64) *float64 {
return clone(kmh)
}
func clone(v *float64) *float64 {
if v == nil {
return nil
}
out := *v
return &out
}
func round(v float64, precision int) float64 {
pow := math.Pow(10, float64(precision))
return math.Round(v*pow) / pow
}

Some files were not shown because too many files have changed in this diff Show More