Compare commits
62 Commits
old-weathe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3136131519 | |||
| 8578db99b3 | |||
| 86fd848a6f | |||
| 6f8e01729d | |||
| 3c7ba89e15 | |||
| cfe6748330 | |||
| 5a1134b955 | |||
| 2a33fe01cf | |||
| f4dd701204 | |||
| 6316783c3a | |||
| cdfe8881d9 | |||
| 8d0bc90f9d | |||
| 12a7447494 | |||
| dd2f24316c | |||
| 90abc536dd | |||
| 370d7c0928 | |||
| b556052966 | |||
| c7760742e6 | |||
| beeaeeaecb | |||
| 1f2459923f | |||
| fa1b2cb390 | |||
| 82dd304f10 | |||
| 14c6954296 | |||
| 0135eb1153 | |||
| b4149f00c5 | |||
| 83cb9abcb9 | |||
| 993621e3b3 | |||
| e897ae52df | |||
| d6734d5ffc | |||
| 63c8f33a2a | |||
| 6e0b71f6f4 | |||
| 27d6183b24 | |||
| 9b29cb388c | |||
| a31f97f807 | |||
| 3ec0a9bd84 | |||
| 42321eb166 | |||
| 9fe480d3ab | |||
| ec027e34cc | |||
| a0f5e85516 | |||
| b4bdd2e4f1 | |||
| a97de4c720 | |||
| ecea856e8e | |||
| bde515d146 | |||
| 626df6bfc1 | |||
| 6ea27cb2c5 | |||
| 0bccfc50d9 | |||
| 291a9178c8 | |||
| 78dc7817e9 | |||
| dbefa8ed28 | |||
| b3ac19a65d | |||
| 6806de3c0a | |||
| bb18bcfb15 | |||
| 8deb4fd12e | |||
| 26a52f8c44 | |||
| 6e8adcc9cc | |||
| 312e738b25 | |||
| d55b4be7ec | |||
| 56f96c4a7a | |||
| e7a9893824 | |||
| a74591382a | |||
| 67beeb4d5e | |||
| b01383e530 |
25
.woodpecker/build-image.yml
Normal file
25
.woodpecker/build-image.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
when:
|
||||
# Allow both normal runs (push) and UI-triggered runs (manual)
|
||||
- event: [push, manual]
|
||||
|
||||
steps:
|
||||
- name: build-and-push-image
|
||||
image: harbor.maximumdirect.net/proxy-dockerhub/woodpeckerci/plugin-kaniko
|
||||
environment:
|
||||
GITEA_USER:
|
||||
from_secret: GITEA_USER
|
||||
GITEA_TOKEN:
|
||||
from_secret: GITEA_TOKEN
|
||||
settings:
|
||||
registry: harbor.maximumdirect.net
|
||||
repo: build/weatherapi
|
||||
auto_tag: true
|
||||
build_args_from_env:
|
||||
- GITEA_USER
|
||||
- GITEA_TOKEN
|
||||
username:
|
||||
from_secret: HARBOR_ROBOT_USER
|
||||
password:
|
||||
from_secret: HARBOR_ROBOT_TOKEN
|
||||
cache: true
|
||||
cache_repo: build-cache/weatherapi
|
||||
4
AGENTS.md
Normal file
4
AGENTS.md
Normal 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.
|
||||
87
Dockerfile
Normal file
87
Dockerfile
Normal file
@@ -0,0 +1,87 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
ARG GO_VERSION=1.25
|
||||
ARG GITEA_USER
|
||||
ARG GITEA_TOKEN
|
||||
|
||||
############################
|
||||
# Build stage
|
||||
############################
|
||||
FROM harbor.maximumdirect.net/proxy-dockerhub/golang:${GO_VERSION}-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
ARG GITEA_USER
|
||||
ARG GITEA_TOKEN
|
||||
ENV GOPRIVATE=gitea.maximumdirect.net/ejr/* \
|
||||
GONOSUMDB=gitea.maximumdirect.net/ejr/*
|
||||
|
||||
# Install baseline packages
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata git build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Cache dependencies first
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
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 . .
|
||||
|
||||
# Default to a static build (no CGO)
|
||||
# If errors, can build with: --build-arg CGO_ENABLED=1
|
||||
ARG CGO_ENABLED=0
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
ENV CGO_ENABLED=${CGO_ENABLED} \
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH}
|
||||
|
||||
# Run tests before building the final binary
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
git config --global url."https://${GITEA_USER}:${GITEA_TOKEN}@gitea.maximumdirect.net/".insteadOf "https://gitea.maximumdirect.net/" && \
|
||||
go test ./...
|
||||
|
||||
# Build the cmd entrypoint
|
||||
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 \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/weatherapi \
|
||||
./cmd/weatherapi
|
||||
|
||||
|
||||
############################
|
||||
# Runtime stage
|
||||
############################
|
||||
FROM harbor.maximumdirect.net/proxy-dockerhub/debian:bookworm-slim AS runtime
|
||||
|
||||
# Install runtime necessities
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Define /weatherapi as the working directory
|
||||
WORKDIR /weatherapi
|
||||
|
||||
# Create an unprivileged user
|
||||
RUN useradd \
|
||||
--uid 10001 \
|
||||
--no-create-home \
|
||||
--shell /usr/sbin/nologin \
|
||||
weatherapi
|
||||
|
||||
# Copy the binary
|
||||
COPY --chown=weatherapi:weatherapi --from=build /out/weatherapi /weatherapi/weatherapi
|
||||
COPY --chown=weatherapi:weatherapi config.yml /weatherapi/config.yml
|
||||
COPY --chown=weatherapi:weatherapi templates /weatherapi/templates
|
||||
|
||||
USER weatherapi
|
||||
|
||||
# The application expects config.yml in the same directory as the binary
|
||||
ENTRYPOINT ["/weatherapi/weatherapi"]
|
||||
45
README.md
45
README.md
@@ -1,3 +1,46 @@
|
||||
# weatherapi
|
||||
|
||||
A small HTTP API that serves a variety of weather-related endpoints.
|
||||
`weatherapi` is a read-only HTTP API for weather data already stored by
|
||||
`weatherfeeder` in PostgreSQL. It exposes the latest observations, current
|
||||
conditions, alerts, forecasts, discussions, weather stories, and convective
|
||||
outlooks as JSON, XML, or text.
|
||||
|
||||
## Quickstart
|
||||
|
||||
The service needs a reachable Postgres database with weatherfeeder-owned tables
|
||||
and the configured text template directory.
|
||||
|
||||
```sh
|
||||
go run ./cmd/weatherapi -config config.yml
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Endpoint Families
|
||||
|
||||
- Observations and current conditions
|
||||
- Active alerts
|
||||
- Hourly and narrative forecasts
|
||||
- Forecast discussions
|
||||
- Weather stories
|
||||
- Convective outlooks
|
||||
|
||||
Common query parameters include `format`, `units`, and route-specific options
|
||||
such as forecast `precision`, timezone `tz` / `TZ`, and outlook filters
|
||||
`day` and `outlookType`.
|
||||
|
||||
See [`docs/api.md`](docs/api.md) for the HTTP contract.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`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
|
||||
|
||||
85
cmd/weatherapi/main.go
Normal file
85
cmd/weatherapi/main.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// main.go wires configuration, dependencies, and HTTP runtime startup.
|
||||
// Layer: cmd/weatherapi executable composition root.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
feedapp "gitea.maximumdirect.net/ejr/feedapi/app"
|
||||
"gitea.maximumdirect.net/ejr/feedapi/config"
|
||||
"gitea.maximumdirect.net/ejr/feedapi/db"
|
||||
httpapi "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi"
|
||||
wfpq "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/outbound/postgres"
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
|
||||
cfgPath := flag.String("config", envOrDefault("WEATHERAPI_CONFIG", "config.yml"), "Path to config YAML")
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := run(ctx, *cfgPath); err != nil {
|
||||
log.Fatalf("weatherapi failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfgPath string) error {
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
if len(cfg.Databases) == 0 {
|
||||
return fmt.Errorf("config.databases requires at least one entry")
|
||||
}
|
||||
|
||||
reg, err := db.OpenAll(cfg.Databases)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open databases: %w", err)
|
||||
}
|
||||
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 envOrDefault(key, fallback string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
13
config.yml
Normal file
13
config.yml
Normal 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
|
||||
507
docs/api.md
Normal file
507
docs/api.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# 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 from recent `observations` rows. Numeric fields are
|
||||
aggregated over the implemented 30-minute observation window. `conditionCode`
|
||||
is selected from the latest observation per source in that window by
|
||||
source-balanced WMO family consensus.
|
||||
|
||||
Query parameters: `format`, `units`, `precision`.
|
||||
|
||||
Common `data` fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `conditionCode` | integer | WMO weather code selected by source-balanced family consensus |
|
||||
| `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,
|
||||
omitting older alerts superseded by newer alert references in the same run.
|
||||
|
||||
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 the alert end boundary is absent or after request
|
||||
time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as
|
||||
a fallback for older rows or providers that do not supply an alert-period end.
|
||||
`onset` is presented when available but is not used as the active boundary.
|
||||
After active-time filtering, alerts referenced by another alert in the same run
|
||||
are omitted as superseded. References from update and cancel messages are both
|
||||
honored, even when the referencing alert is not itself returned.
|
||||
|
||||
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
||||
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
||||
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
|
||||
`areaDescription`, `senderName`, and `references`. Most alert fields are
|
||||
optional except `id` when an alert item is present. `ends` is the alert-period
|
||||
end; `expires` is provider expiration metadata.
|
||||
|
||||
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
69
docs/cli.md
Normal 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
132
docs/config.md
Normal 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
|
||||
140
docs/integrations/feedapi.md
Normal file
140
docs/integrations/feedapi.md
Normal 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)
|
||||
205
docs/integrations/weatherfeeder-postgres.md
Normal file
205
docs/integrations/weatherfeeder-postgres.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 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 numeric values from `observations` rows where
|
||||
`observed_at` is inside the application-provided observation window. They also
|
||||
use the latest row per `event_source` in that window to select `condition_code`
|
||||
by source-balanced WMO family consensus.
|
||||
|
||||
## 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`, `event_source`, `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 read recent `observations` values for
|
||||
temperature, apparent temperature, dewpoint, humidity, wind speed, wind
|
||||
direction, latest `is_day`, and latest condition-code candidates per
|
||||
`event_source`.
|
||||
|
||||
### `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`, `ends`, `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)
|
||||
184
docs/internal/http-adapter.md
Normal file
184
docs/internal/http-adapter.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 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. The
|
||||
application service prefers alert `ends` over `expires` when deciding whether an
|
||||
alert has ended. The application service also suppresses alerts referenced by
|
||||
another alert in the same latest run. This supersession rule uses alert
|
||||
references from update and cancel messages, even when the referencing message is
|
||||
not returned by `/alerts/active`.
|
||||
|
||||
## 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.
|
||||
168
docs/internal/postgres-repository.md
Normal file
168
docs/internal/postgres-repository.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 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`: reads an aggregate row from recent `observations` using
|
||||
the application-provided observation window, then reads the latest
|
||||
condition-code candidate per `event_source` in the same window.
|
||||
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
||||
`alert_references`. This is the latest stored alert snapshot. The repository
|
||||
maps both `ends` and `expires`; 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
148
docs/internal/presenters.md
Normal 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
105
docs/internal/runtime.md
Normal 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
192
docs/operations.md
Normal 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
196
docs/policy/architecture.md
Normal 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
159
docs/policy/development.md
Normal 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/`.
|
||||
444
docs/policy/documentation.md
Normal file
444
docs/policy/documentation.md
Normal 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 project’s 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
281
docs/troubleshooting.md
Normal 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
|
||||
13
examples/config.minimal.yml
Normal file
13
examples/config.minimal.yml
Normal 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
|
||||
20
examples/config.production.yml
Normal file
20
examples/config.production.yml
Normal 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
52
examples/requests.http
Normal 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
|
||||
8
go.mod
8
go.mod
@@ -1,3 +1,11 @@
|
||||
module gitea.maximumdirect.net/ejr/weatherapi
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1
|
||||
github.com/lib/pq v1.10.9
|
||||
)
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
10
go.sum
Normal file
10
go.sum
Normal file
@@ -0,0 +1,10 @@
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1 h1:dZYDpOd0vEIk6QljsrhFyrOY0Lt4WyEoazhqDFIZKNQ=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
31
internal/adapters/inbound/httpapi/alerts_endpoint.go
Normal file
31
internal/adapters/inbound/httpapi/alerts_endpoint.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
28
internal/adapters/inbound/httpapi/conditions_endpoint.go
Normal file
28
internal/adapters/inbound/httpapi/conditions_endpoint.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
72
internal/adapters/inbound/httpapi/discussion_endpoint.go
Normal file
72
internal/adapters/inbound/httpapi/discussion_endpoint.go
Normal 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),
|
||||
)
|
||||
}
|
||||
18
internal/adapters/inbound/httpapi/endpoints.go
Normal file
18
internal/adapters/inbound/httpapi/endpoints.go
Normal 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
|
||||
}
|
||||
2667
internal/adapters/inbound/httpapi/endpoints_test.go
Normal file
2667
internal/adapters/inbound/httpapi/endpoints_test.go
Normal file
File diff suppressed because it is too large
Load Diff
108
internal/adapters/inbound/httpapi/forecast_endpoint.go
Normal file
108
internal/adapters/inbound/httpapi/forecast_endpoint.go
Normal 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
|
||||
}
|
||||
28
internal/adapters/inbound/httpapi/observations_endpoint.go
Normal file
28
internal/adapters/inbound/httpapi/observations_endpoint.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
57
internal/adapters/inbound/httpapi/outlooks_endpoint.go
Normal file
57
internal/adapters/inbound/httpapi/outlooks_endpoint.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
12
internal/adapters/inbound/httpapi/presenter/alerts.go
Normal file
12
internal/adapters/inbound/httpapi/presenter/alerts.go
Normal 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
|
||||
}
|
||||
57
internal/adapters/inbound/httpapi/presenter/conditions.go
Normal file
57
internal/adapters/inbound/httpapi/presenter/conditions.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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/model"
|
||||
"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"`
|
||||
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
|
||||
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),
|
||||
ConditionCode: conditions.ConditionCode,
|
||||
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
|
||||
}
|
||||
13
internal/adapters/inbound/httpapi/presenter/constants.go
Normal file
13
internal/adapters/inbound/httpapi/presenter/constants.go
Normal 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
|
||||
)
|
||||
110
internal/adapters/inbound/httpapi/presenter/discussion.go
Normal file
110
internal/adapters/inbound/httpapi/presenter/discussion.go
Normal 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,
|
||||
}
|
||||
}
|
||||
134
internal/adapters/inbound/httpapi/presenter/forecast.go
Normal file
134
internal/adapters/inbound/httpapi/presenter/forecast.go
Normal 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
|
||||
}
|
||||
99
internal/adapters/inbound/httpapi/presenter/helpers.go
Normal file
99
internal/adapters/inbound/httpapi/presenter/helpers.go
Normal 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
|
||||
}
|
||||
76
internal/adapters/inbound/httpapi/presenter/observation.go
Normal file
76
internal/adapters/inbound/httpapi/presenter/observation.go
Normal 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
|
||||
}
|
||||
77
internal/adapters/inbound/httpapi/presenter/outlook.go
Normal file
77
internal/adapters/inbound/httpapi/presenter/outlook.go
Normal 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
|
||||
}
|
||||
645
internal/adapters/inbound/httpapi/presenter/payload_test.go
Normal file
645
internal/adapters/inbound/httpapi/presenter/payload_test.go
Normal file
@@ -0,0 +1,645 @@
|
||||
// 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)
|
||||
}
|
||||
if metric.ConditionCode != 0 {
|
||||
t.Fatalf("expected condition code 0, got %d", metric.ConditionCode)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if us.ConditionCode != 0 {
|
||||
t.Fatalf("expected condition code 0, got %d", us.ConditionCode)
|
||||
}
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
11
internal/adapters/inbound/httpapi/presenter/units.go
Normal file
11
internal/adapters/inbound/httpapi/presenter/units.go
Normal 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"
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
209
internal/adapters/inbound/httpapi/query_bind.go
Normal file
209
internal/adapters/inbound/httpapi/query_bind.go
Normal 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
|
||||
}
|
||||
23
internal/adapters/inbound/httpapi/query_normalize.go
Normal file
23
internal/adapters/inbound/httpapi/query_normalize.go
Normal 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()
|
||||
}
|
||||
140
internal/adapters/inbound/httpapi/query_timezone.go
Normal file
140
internal/adapters/inbound/httpapi/query_timezone.go
Normal 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)
|
||||
}
|
||||
25
internal/adapters/inbound/httpapi/service.go
Normal file
25
internal/adapters/inbound/httpapi/service.go
Normal 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)
|
||||
}
|
||||
43
internal/adapters/inbound/httpapi/weatherstories_endpoint.go
Normal file
43
internal/adapters/inbound/httpapi/weatherstories_endpoint.go
Normal 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"),
|
||||
),
|
||||
}
|
||||
}
|
||||
71
internal/adapters/outbound/postgres/alerts_mapper.go
Normal file
71
internal/adapters/outbound/postgres/alerts_mapper.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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),
|
||||
Ends: timePtr(row.Ends),
|
||||
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
|
||||
}
|
||||
54
internal/adapters/outbound/postgres/alerts_queries.go
Normal file
54
internal/adapters/outbound/postgres/alerts_queries.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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,
|
||||
ends,
|
||||
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`
|
||||
)
|
||||
111
internal/adapters/outbound/postgres/alerts_read.go
Normal file
111
internal/adapters/outbound/postgres/alerts_read.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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.Ends,
|
||||
&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
|
||||
}
|
||||
60
internal/adapters/outbound/postgres/alerts_rows.go
Normal file
60
internal/adapters/outbound/postgres/alerts_rows.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// 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
|
||||
Ends 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
|
||||
}
|
||||
107
internal/adapters/outbound/postgres/conditions_codes.go
Normal file
107
internal/adapters/outbound/postgres/conditions_codes.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// conditions_codes.go selects current-conditions WMO codes from source candidates.
|
||||
// Layer: adapters/outbound/postgres conditions feature.
|
||||
package postgres
|
||||
|
||||
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
|
||||
type currentConditionsConditionCodeCandidate struct {
|
||||
EventSource string
|
||||
ConditionCode model.WMOCode
|
||||
}
|
||||
|
||||
type conditionCodeFamily int
|
||||
|
||||
const (
|
||||
conditionCodeFamilyUnknown conditionCodeFamily = iota
|
||||
conditionCodeFamilyClearOrCloud
|
||||
conditionCodeFamilyFog
|
||||
conditionCodeFamilyDrizzle
|
||||
conditionCodeFamilyRain
|
||||
conditionCodeFamilySnow
|
||||
conditionCodeFamilyThunderstorm
|
||||
)
|
||||
|
||||
var currentConditionsConditionCodeRankings = map[conditionCodeFamily][]model.WMOCode{
|
||||
conditionCodeFamilyClearOrCloud: {0, 1, 2, 3},
|
||||
conditionCodeFamilyFog: {45, 48},
|
||||
conditionCodeFamilyDrizzle: {51, 53, 55, 56, 57},
|
||||
conditionCodeFamilyRain: {61, 63, 65, 80, 81, 82, 66, 67},
|
||||
conditionCodeFamilySnow: {71, 73, 75, 85, 86, 77},
|
||||
conditionCodeFamilyThunderstorm: {95, 96, 99},
|
||||
}
|
||||
|
||||
var currentConditionsConditionCodeFamilies = buildCurrentConditionsConditionCodeFamilies()
|
||||
|
||||
func buildCurrentConditionsConditionCodeFamilies() map[model.WMOCode]conditionCodeFamily {
|
||||
families := make(map[model.WMOCode]conditionCodeFamily)
|
||||
for family, ranking := range currentConditionsConditionCodeRankings {
|
||||
for _, code := range ranking {
|
||||
families[code] = family
|
||||
}
|
||||
}
|
||||
return families
|
||||
}
|
||||
|
||||
func selectCurrentConditionsConditionCode(candidates []currentConditionsConditionCodeCandidate) model.WMOCode {
|
||||
seenSources := make(map[string]struct{})
|
||||
familyCounts := make(map[conditionCodeFamily]int)
|
||||
codeCounts := make(map[model.WMOCode]int)
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if _, seen := seenSources[candidate.EventSource]; seen {
|
||||
continue
|
||||
}
|
||||
seenSources[candidate.EventSource] = struct{}{}
|
||||
|
||||
family, ok := currentConditionsConditionCodeFamilies[candidate.ConditionCode]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
familyCounts[family]++
|
||||
codeCounts[candidate.ConditionCode]++
|
||||
}
|
||||
|
||||
winningFamily, ok := currentConditionsWinningConditionCodeFamily(familyCounts)
|
||||
if !ok {
|
||||
return model.WMOUnknown
|
||||
}
|
||||
|
||||
return currentConditionsWinningConditionCode(winningFamily, codeCounts)
|
||||
}
|
||||
|
||||
func currentConditionsWinningConditionCodeFamily(counts map[conditionCodeFamily]int) (conditionCodeFamily, bool) {
|
||||
winningFamily := conditionCodeFamilyUnknown
|
||||
winningCount := 0
|
||||
tied := false
|
||||
|
||||
for family, count := range counts {
|
||||
if count > winningCount {
|
||||
winningFamily = family
|
||||
winningCount = count
|
||||
tied = false
|
||||
continue
|
||||
}
|
||||
if count == winningCount {
|
||||
tied = true
|
||||
}
|
||||
}
|
||||
|
||||
if winningCount == 0 || tied {
|
||||
return conditionCodeFamilyUnknown, false
|
||||
}
|
||||
return winningFamily, true
|
||||
}
|
||||
|
||||
func currentConditionsWinningConditionCode(family conditionCodeFamily, counts map[model.WMOCode]int) model.WMOCode {
|
||||
winningCode := model.WMOUnknown
|
||||
winningCount := 0
|
||||
|
||||
for _, code := range currentConditionsConditionCodeRankings[family] {
|
||||
if counts[code] > winningCount {
|
||||
winningCode = code
|
||||
winningCount = counts[code]
|
||||
}
|
||||
}
|
||||
|
||||
return winningCode
|
||||
}
|
||||
116
internal/adapters/outbound/postgres/conditions_codes_test.go
Normal file
116
internal/adapters/outbound/postgres/conditions_codes_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// conditions_codes_test.go tests current-conditions WMO code selection.
|
||||
// Layer: adapters/outbound/postgres conditions feature.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
)
|
||||
|
||||
func TestSelectCurrentConditionsConditionCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []currentConditionsConditionCodeCandidate
|
||||
want model.WMOCode
|
||||
}{
|
||||
{
|
||||
name: "clear cloud ranking breaks exact tie",
|
||||
candidates: conditionCodeCandidates(
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
),
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "clear cloud family wins over thunderstorm",
|
||||
candidates: conditionCodeCandidates(
|
||||
1,
|
||||
2,
|
||||
95,
|
||||
),
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "tied families return unknown",
|
||||
candidates: conditionCodeCandidates(
|
||||
0,
|
||||
95,
|
||||
),
|
||||
want: model.WMOUnknown,
|
||||
},
|
||||
{
|
||||
name: "rain ranking breaks exact tie",
|
||||
candidates: conditionCodeCandidates(
|
||||
61,
|
||||
63,
|
||||
80,
|
||||
),
|
||||
want: 61,
|
||||
},
|
||||
{
|
||||
name: "three tied families return unknown",
|
||||
candidates: conditionCodeCandidates(
|
||||
61,
|
||||
95,
|
||||
0,
|
||||
),
|
||||
want: model.WMOUnknown,
|
||||
},
|
||||
{
|
||||
name: "single thunderstorm code wins",
|
||||
candidates: conditionCodeCandidates(
|
||||
95,
|
||||
),
|
||||
want: 95,
|
||||
},
|
||||
{
|
||||
name: "unrecognized only returns unknown",
|
||||
candidates: conditionCodeCandidates(
|
||||
4,
|
||||
100,
|
||||
),
|
||||
want: model.WMOUnknown,
|
||||
},
|
||||
{
|
||||
name: "duplicate source only votes once",
|
||||
candidates: []currentConditionsConditionCodeCandidate{
|
||||
{EventSource: "source-1", ConditionCode: 95},
|
||||
{EventSource: "source-1", ConditionCode: 95},
|
||||
{EventSource: "source-2", ConditionCode: 0},
|
||||
{EventSource: "source-3", ConditionCode: 0},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "exact code frequency wins before ranking",
|
||||
candidates: []currentConditionsConditionCodeCandidate{
|
||||
{EventSource: "source-1", ConditionCode: 61},
|
||||
{EventSource: "source-2", ConditionCode: 63},
|
||||
{EventSource: "source-3", ConditionCode: 63},
|
||||
},
|
||||
want: 63,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := selectCurrentConditionsConditionCode(tt.candidates)
|
||||
if got != tt.want {
|
||||
t.Fatalf("expected condition code %d, got %d", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func conditionCodeCandidates(codes ...model.WMOCode) []currentConditionsConditionCodeCandidate {
|
||||
candidates := make([]currentConditionsConditionCodeCandidate, 0, len(codes))
|
||||
for i, code := range codes {
|
||||
candidates = append(candidates, currentConditionsConditionCodeCandidate{
|
||||
EventSource: string(rune('a' + i)),
|
||||
ConditionCode: code,
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
25
internal/adapters/outbound/postgres/conditions_mapper.go
Normal file
25
internal/adapters/outbound/postgres/conditions_mapper.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// 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, conditionCode model.WMOCode) *app.CurrentConditions {
|
||||
if row.SampleCount == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
67
internal/adapters/outbound/postgres/conditions_queries.go
Normal file
67
internal/adapters/outbound/postgres/conditions_queries.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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,
|
||||
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,
|
||||
(
|
||||
SELECT is_day
|
||||
FROM windowed
|
||||
ORDER BY observed_at DESC
|
||||
LIMIT 1
|
||||
) AS is_day
|
||||
FROM windowed`
|
||||
|
||||
queryCurrentConditionsConditionCodeCandidates = `
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
event_source,
|
||||
condition_code,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY event_source
|
||||
ORDER BY observed_at DESC, event_emitted_at DESC
|
||||
) AS source_rank
|
||||
FROM observations
|
||||
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
|
||||
)
|
||||
SELECT
|
||||
event_source,
|
||||
condition_code
|
||||
FROM ranked
|
||||
WHERE source_rank = 1
|
||||
ORDER BY event_source`
|
||||
)
|
||||
76
internal/adapters/outbound/postgres/conditions_read.go
Normal file
76
internal/adapters/outbound/postgres/conditions_read.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
)
|
||||
|
||||
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.IsDay,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query current conditions: %w", err)
|
||||
}
|
||||
|
||||
if row.SampleCount == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
candidates, err := r.currentConditionsConditionCodeCandidates(ctx, observationWindowMinutes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mapCurrentConditionsRow(row, selectCurrentConditionsConditionCode(candidates)), nil
|
||||
}
|
||||
|
||||
func (r *Repository) currentConditionsConditionCodeCandidates(ctx context.Context, observationWindowMinutes int) ([]currentConditionsConditionCodeCandidate, error) {
|
||||
rows, err := r.db.QueryContext(ctx, queryCurrentConditionsConditionCodeCandidates, observationWindowMinutes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query current conditions condition code candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var candidates []currentConditionsConditionCodeCandidate
|
||||
for rows.Next() {
|
||||
var (
|
||||
eventSource string
|
||||
conditionCode int64
|
||||
)
|
||||
if err := rows.Scan(&eventSource, &conditionCode); err != nil {
|
||||
return nil, fmt.Errorf("scan current conditions condition code candidate: %w", err)
|
||||
}
|
||||
candidates = append(candidates, currentConditionsConditionCodeCandidate{
|
||||
EventSource: eventSource,
|
||||
ConditionCode: model.WMOCode(conditionCode),
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate current conditions condition code candidates: %w", err)
|
||||
}
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
280
internal/adapters/outbound/postgres/conditions_read_test.go
Normal file
280
internal/adapters/outbound/postgres/conditions_read_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// conditions_read_test.go validates current-conditions repository read flow.
|
||||
// Layer: adapters/outbound/postgres conditions read tests.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const currentConditionsTestDriverName = "weatherapi_current_conditions_read_test"
|
||||
|
||||
func init() {
|
||||
sql.Register(currentConditionsTestDriverName, currentConditionsTestDriver{})
|
||||
}
|
||||
|
||||
func TestCurrentConditionsUsesConsensusConditionCode(t *testing.T) {
|
||||
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||
currentConditionsAggregateQuery(currentConditionsAggregateRow(3), nil),
|
||||
currentConditionsConditionCodeCandidatesQuery([][]driver.Value{
|
||||
{"source-a", int64(1)},
|
||||
{"source-b", int64(2)},
|
||||
{"source-c", int64(95)},
|
||||
}, nil),
|
||||
)
|
||||
defer closeDB()
|
||||
|
||||
conditions, err := repo.CurrentConditions(context.Background(), 15)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if conditions == nil {
|
||||
t.Fatal("expected current conditions")
|
||||
}
|
||||
if conditions.ConditionCode != 1 {
|
||||
t.Fatalf("expected consensus condition code 1, got %d", conditions.ConditionCode)
|
||||
}
|
||||
if conditions.TemperatureC == nil || *conditions.TemperatureC != 15.5 {
|
||||
t.Fatalf("expected temperature 15.5, got %v", conditions.TemperatureC)
|
||||
}
|
||||
if conditions.WindDirectionDegrees == nil || *conditions.WindDirectionDegrees != 182.5 {
|
||||
t.Fatalf("expected wind direction 182.5, got %v", conditions.WindDirectionDegrees)
|
||||
}
|
||||
if conditions.IsDay == nil || !*conditions.IsDay {
|
||||
t.Fatalf("expected isDay true, got %v", conditions.IsDay)
|
||||
}
|
||||
assertCurrentConditionsTestQueriesConsumed(t)
|
||||
}
|
||||
|
||||
func TestCurrentConditionsNoSamplesSkipsConditionCodeCandidates(t *testing.T) {
|
||||
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||
currentConditionsAggregateQuery([]driver.Value{int64(0), nil, nil, nil, nil, nil, nil, nil}, nil),
|
||||
)
|
||||
defer closeDB()
|
||||
|
||||
conditions, err := repo.CurrentConditions(context.Background(), 15)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if conditions != nil {
|
||||
t.Fatalf("expected nil current conditions, got %+v", conditions)
|
||||
}
|
||||
assertCurrentConditionsTestQueriesConsumed(t)
|
||||
}
|
||||
|
||||
func TestCurrentConditionsConditionCodeCandidateQueryUsesLatestPerSourceOrdering(t *testing.T) {
|
||||
query := compactSQL(queryCurrentConditionsConditionCodeCandidates)
|
||||
want := "PARTITION BY event_source ORDER BY observed_at DESC, event_emitted_at DESC"
|
||||
if !strings.Contains(query, want) {
|
||||
t.Fatalf("expected condition code candidate query to contain %q, got %q", want, query)
|
||||
}
|
||||
if !strings.Contains(query, "SELECT event_source, condition_code") {
|
||||
t.Fatalf("expected condition code candidate query to select event_source and condition_code, got %q", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsAggregateQueryDoesNotSelectConditionCode(t *testing.T) {
|
||||
query := compactSQL(queryCurrentConditions)
|
||||
if strings.Contains(query, "condition_code") {
|
||||
t.Fatalf("expected aggregate query not to select condition_code, got %q", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsConditionCodeCandidateQueryWrapsErrors(t *testing.T) {
|
||||
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||
currentConditionsAggregateQuery(currentConditionsAggregateRow(1), nil),
|
||||
scriptedCurrentConditionsQuery{
|
||||
name: "condition code candidates",
|
||||
query: queryCurrentConditionsConditionCodeCandidates,
|
||||
args: []driver.Value{int64(15)},
|
||||
err: errors.New("candidate query unavailable"),
|
||||
},
|
||||
)
|
||||
defer closeDB()
|
||||
|
||||
_, err := repo.CurrentConditions(context.Background(), 15)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "query current conditions condition code candidates") {
|
||||
t.Fatalf("expected candidate query context, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openCurrentConditionsTestRepository(t *testing.T, queries ...scriptedCurrentConditionsQuery) (*Repository, func()) {
|
||||
t.Helper()
|
||||
currentConditionsTestScript.set(queries)
|
||||
|
||||
db, err := sql.Open(currentConditionsTestDriverName, "")
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
return NewRepository(db), func() {
|
||||
_ = db.Close()
|
||||
currentConditionsTestScript.set(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCurrentConditionsTestQueriesConsumed(t *testing.T) {
|
||||
t.Helper()
|
||||
if remaining := currentConditionsTestScript.remaining(); remaining != 0 {
|
||||
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func currentConditionsAggregateQuery(row []driver.Value, nextErr error) scriptedCurrentConditionsQuery {
|
||||
return scriptedCurrentConditionsQuery{
|
||||
name: "aggregate",
|
||||
query: queryCurrentConditions,
|
||||
args: []driver.Value{int64(15)},
|
||||
columns: []string{"sample_count", "temperature_c", "apparent_temperature_c", "dewpoint_c", "relative_humidity_percent", "wind_speed_kmh", "wind_direction_degrees", "is_day"},
|
||||
rows: [][]driver.Value{row},
|
||||
nextErr: nextErr,
|
||||
}
|
||||
}
|
||||
|
||||
func currentConditionsConditionCodeCandidatesQuery(rows [][]driver.Value, nextErr error) scriptedCurrentConditionsQuery {
|
||||
return scriptedCurrentConditionsQuery{
|
||||
name: "condition code candidates",
|
||||
query: queryCurrentConditionsConditionCodeCandidates,
|
||||
args: []driver.Value{int64(15)},
|
||||
columns: []string{"event_source", "condition_code"},
|
||||
rows: rows,
|
||||
nextErr: nextErr,
|
||||
}
|
||||
}
|
||||
|
||||
func currentConditionsAggregateRow(sampleCount int64) []driver.Value {
|
||||
return []driver.Value{
|
||||
sampleCount,
|
||||
float64(15.5),
|
||||
float64(14.2),
|
||||
float64(10.1),
|
||||
float64(72),
|
||||
float64(24.8),
|
||||
float64(182.5),
|
||||
true,
|
||||
}
|
||||
}
|
||||
|
||||
type currentConditionsTestDriver struct{}
|
||||
|
||||
func (currentConditionsTestDriver) Open(string) (driver.Conn, error) {
|
||||
return currentConditionsTestConn{}, nil
|
||||
}
|
||||
|
||||
type currentConditionsTestConn struct{}
|
||||
|
||||
func (currentConditionsTestConn) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("prepare is not supported")
|
||||
}
|
||||
|
||||
func (currentConditionsTestConn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (currentConditionsTestConn) Begin() (driver.Tx, error) {
|
||||
return nil, errors.New("transactions are not supported")
|
||||
}
|
||||
|
||||
func (currentConditionsTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
return currentConditionsTestScript.next(query, args)
|
||||
}
|
||||
|
||||
type scriptedCurrentConditionsQuery struct {
|
||||
name string
|
||||
query string
|
||||
args []driver.Value
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
err error
|
||||
nextErr error
|
||||
}
|
||||
|
||||
type currentConditionsTestScriptState struct {
|
||||
mu sync.Mutex
|
||||
queries []scriptedCurrentConditionsQuery
|
||||
}
|
||||
|
||||
var currentConditionsTestScript currentConditionsTestScriptState
|
||||
|
||||
func (s *currentConditionsTestScriptState) set(queries []scriptedCurrentConditionsQuery) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.queries = append([]scriptedCurrentConditionsQuery(nil), queries...)
|
||||
}
|
||||
|
||||
func (s *currentConditionsTestScriptState) remaining() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.queries)
|
||||
}
|
||||
|
||||
func (s *currentConditionsTestScriptState) 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 ¤tConditionsTestRows{
|
||||
columns: append([]string(nil), next.columns...),
|
||||
rows: append([][]driver.Value(nil), next.rows...),
|
||||
nextErr: next.nextErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type currentConditionsTestRows struct {
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
index int
|
||||
nextErr error
|
||||
}
|
||||
|
||||
func (r *currentConditionsTestRows) Columns() []string {
|
||||
return r.columns
|
||||
}
|
||||
|
||||
func (r *currentConditionsTestRows) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *currentConditionsTestRows) 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
|
||||
}
|
||||
16
internal/adapters/outbound/postgres/conditions_rows.go
Normal file
16
internal/adapters/outbound/postgres/conditions_rows.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// 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
|
||||
IsDay sql.NullBool
|
||||
}
|
||||
45
internal/adapters/outbound/postgres/discussion_mapper.go
Normal file
45
internal/adapters/outbound/postgres/discussion_mapper.go
Normal 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,
|
||||
}
|
||||
}
|
||||
31
internal/adapters/outbound/postgres/discussion_queries.go
Normal file
31
internal/adapters/outbound/postgres/discussion_queries.go
Normal 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`
|
||||
)
|
||||
74
internal/adapters/outbound/postgres/discussion_read.go
Normal file
74
internal/adapters/outbound/postgres/discussion_read.go
Normal 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
|
||||
}
|
||||
28
internal/adapters/outbound/postgres/discussion_rows.go
Normal file
28
internal/adapters/outbound/postgres/discussion_rows.go
Normal 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
|
||||
}
|
||||
57
internal/adapters/outbound/postgres/forecast_mapper.go
Normal file
57
internal/adapters/outbound/postgres/forecast_mapper.go
Normal 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
|
||||
}
|
||||
66
internal/adapters/outbound/postgres/forecast_queries.go
Normal file
66
internal/adapters/outbound/postgres/forecast_queries.go
Normal 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`
|
||||
)
|
||||
104
internal/adapters/outbound/postgres/forecast_read.go
Normal file
104
internal/adapters/outbound/postgres/forecast_read.go
Normal 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
|
||||
}
|
||||
46
internal/adapters/outbound/postgres/forecast_rows.go
Normal file
46
internal/adapters/outbound/postgres/forecast_rows.go
Normal 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
|
||||
}
|
||||
42
internal/adapters/outbound/postgres/observations_mapper.go
Normal file
42
internal/adapters/outbound/postgres/observations_mapper.go
Normal 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
|
||||
}
|
||||
33
internal/adapters/outbound/postgres/observations_queries.go
Normal file
33
internal/adapters/outbound/postgres/observations_queries.go
Normal 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`
|
||||
)
|
||||
79
internal/adapters/outbound/postgres/observations_read.go
Normal file
79
internal/adapters/outbound/postgres/observations_read.go
Normal 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
|
||||
}
|
||||
32
internal/adapters/outbound/postgres/observations_rows.go
Normal file
32
internal/adapters/outbound/postgres/observations_rows.go
Normal 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
|
||||
}
|
||||
67
internal/adapters/outbound/postgres/outlooks_mapper.go
Normal file
67
internal/adapters/outbound/postgres/outlooks_mapper.go
Normal 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
|
||||
}
|
||||
216
internal/adapters/outbound/postgres/outlooks_mapper_test.go
Normal file
216
internal/adapters/outbound/postgres/outlooks_mapper_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
54
internal/adapters/outbound/postgres/outlooks_queries.go
Normal file
54
internal/adapters/outbound/postgres/outlooks_queries.go
Normal 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`
|
||||
)
|
||||
124
internal/adapters/outbound/postgres/outlooks_read.go
Normal file
124
internal/adapters/outbound/postgres/outlooks_read.go
Normal 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
|
||||
}
|
||||
405
internal/adapters/outbound/postgres/outlooks_read_test.go
Normal file
405
internal/adapters/outbound/postgres/outlooks_read_test.go
Normal 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
|
||||
}
|
||||
48
internal/adapters/outbound/postgres/outlooks_rows.go
Normal file
48
internal/adapters/outbound/postgres/outlooks_rows.go
Normal 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
|
||||
}
|
||||
20
internal/adapters/outbound/postgres/repository.go
Normal file
20
internal/adapters/outbound/postgres/repository.go
Normal 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}
|
||||
}
|
||||
327
internal/adapters/outbound/postgres/repository_test.go
Normal file
327
internal/adapters/outbound/postgres/repository_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// 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 TestMapAlertRowMapsEndsAndExpires(t *testing.T) {
|
||||
ends := time.Date(2026, 6, 16, 14, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
expires := time.Date(2026, 6, 16, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
|
||||
alert := mapAlertRow(alertRow{
|
||||
AlertIndex: 1,
|
||||
AlertID: "alert-1",
|
||||
Ends: sql.NullTime{Time: ends, Valid: true},
|
||||
Expires: sql.NullTime{Time: expires, Valid: true},
|
||||
}).Alert
|
||||
|
||||
if alert.Ends == nil || alert.Ends.Location().String() != "UTC" || !alert.Ends.Equal(ends.UTC()) {
|
||||
t.Fatalf("expected ends UTC %s, got %v", ends.UTC(), alert.Ends)
|
||||
}
|
||||
if alert.Expires == nil || alert.Expires.Location().String() != "UTC" || !alert.Expires.Equal(expires.UTC()) {
|
||||
t.Fatalf("expected expires UTC %s, got %v", expires.UTC(), alert.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapAlertRowNullableEnds(t *testing.T) {
|
||||
alert := mapAlertRow(alertRow{
|
||||
AlertIndex: 1,
|
||||
AlertID: "alert-1",
|
||||
}).Alert
|
||||
|
||||
if alert.Ends != nil {
|
||||
t.Fatalf("expected nil ends, got %v", alert.Ends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
|
||||
got := mapCurrentConditionsRow(currentConditionsRow{
|
||||
SampleCount: 0,
|
||||
}, model.WMOUnknown)
|
||||
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},
|
||||
IsDay: sql.NullBool{Bool: isDay, Valid: true},
|
||||
}, 65)
|
||||
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)
|
||||
}
|
||||
}
|
||||
39
internal/adapters/outbound/postgres/scan_helpers.go
Normal file
39
internal/adapters/outbound/postgres/scan_helpers.go
Normal 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
|
||||
}
|
||||
28
internal/adapters/outbound/postgres/weatherstories_mapper.go
Normal file
28
internal/adapters/outbound/postgres/weatherstories_mapper.go
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
)
|
||||
103
internal/adapters/outbound/postgres/weatherstories_read.go
Normal file
103
internal/adapters/outbound/postgres/weatherstories_read.go
Normal 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
|
||||
}
|
||||
28
internal/adapters/outbound/postgres/weatherstories_rows.go
Normal file
28
internal/adapters/outbound/postgres/weatherstories_rows.go
Normal 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
|
||||
}
|
||||
7
internal/app/constants.go
Normal file
7
internal/app/constants.go
Normal file
@@ -0,0 +1,7 @@
|
||||
// constants.go defines shared application-level constants.
|
||||
// Layer: internal/app service behavior defaults.
|
||||
package app
|
||||
|
||||
const (
|
||||
ObservationWindowMinutesDefault = 30
|
||||
)
|
||||
17
internal/app/current_conditions.go
Normal file
17
internal/app/current_conditions.go
Normal 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
|
||||
}
|
||||
298
internal/app/service.go
Normal file
298
internal/app/service.go
Normal file
@@ -0,0 +1,298 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
const nwsAlertURLPrefix = "https://api.weather.gov/alerts/"
|
||||
|
||||
// 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)
|
||||
supersededIDs := collectSupersededAlertIDs(out.Alerts)
|
||||
alerts := out.Alerts[:0]
|
||||
for _, alert := range out.Alerts {
|
||||
if isActiveAlert(alert, activeAt) && !isSupersededAlert(alert, supersededIDs) {
|
||||
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.Ends = copyTime(alert.Ends)
|
||||
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
|
||||
}
|
||||
endBoundary := alert.Ends
|
||||
if endBoundary == nil {
|
||||
endBoundary = alert.Expires
|
||||
}
|
||||
if endBoundary != nil && !activeAt.Before(*endBoundary) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func collectSupersededAlertIDs(alerts []model.WeatherAlert) map[string]struct{} {
|
||||
supersededIDs := make(map[string]struct{})
|
||||
for _, alert := range alerts {
|
||||
for _, ref := range alert.References {
|
||||
id := normalizeAlertID(referenceAlertID(ref))
|
||||
if id != "" {
|
||||
supersededIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return supersededIDs
|
||||
}
|
||||
|
||||
func isSupersededAlert(alert model.WeatherAlert, supersededIDs map[string]struct{}) bool {
|
||||
id := normalizeAlertID(alert.ID)
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := supersededIDs[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func referenceAlertID(ref model.AlertReference) string {
|
||||
if strings.TrimSpace(ref.Identifier) != "" {
|
||||
return ref.Identifier
|
||||
}
|
||||
return ref.ID
|
||||
}
|
||||
|
||||
func normalizeAlertID(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, nwsAlertURLPrefix)
|
||||
return value
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
754
internal/app/service_test.go
Normal file
754
internal/app/service_test.go
Normal file
@@ -0,0 +1,754 @@
|
||||
// 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", "ends-preferred"})
|
||||
}
|
||||
|
||||
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), testTimePtr(13)),
|
||||
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
|
||||
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14), 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].Ends = testTime(4)
|
||||
*run.Alerts[0].Expires = testTime(5)
|
||||
*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].Ends == nil || !original.Alerts[0].Ends.Equal(testTime(13)) {
|
||||
t.Fatalf("expected original ends unchanged, got %v", original.Alerts[0].Ends)
|
||||
}
|
||||
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(12)) {
|
||||
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) != 10 {
|
||||
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunUsesEndsBeforeExpires(t *testing.T) {
|
||||
activeAt := testTime(12)
|
||||
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
||||
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
||||
testAlert("ends-after-active-expires-before", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(11)),
|
||||
testAlert("expires-fallback", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(13)),
|
||||
testAlert("expires-fallback-expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(12)),
|
||||
})}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"})
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunSuppressesReferencedOriginal(t *testing.T) {
|
||||
activeAt := testTime(12)
|
||||
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||
update := testAlert("https://api.weather.gov/alerts/urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||
update.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
|
||||
unrelated := testAlert("https://api.weather.gov/alerts/urn:oid:unrelated", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, update, unrelated})}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertAlertIDs(t, run, []string{
|
||||
"https://api.weather.gov/alerts/urn:oid:update",
|
||||
"https://api.weather.gov/alerts/urn:oid:unrelated",
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunCancelSuppressesReferencedOriginal(t *testing.T) {
|
||||
activeAt := testTime(12)
|
||||
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||
cancel := testAlert("https://api.weather.gov/alerts/urn:oid:cancel", "Cancel", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||
cancel.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
|
||||
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, cancel})}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertAlertIDs(t, run, []string{})
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunReferenceIdentifierPrecedenceAndIDFallback(t *testing.T) {
|
||||
activeAt := testTime(12)
|
||||
fromID := testAlert("urn:oid:from-id", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||
fromIdentifier := testAlert("urn:oid:from-identifier", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||
idFallback := testAlert("urn:oid:id-fallback", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||
update := testAlert("urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||
update.References = []model.AlertReference{
|
||||
{ID: "urn:oid:from-id", Identifier: "urn:oid:from-identifier"},
|
||||
{ID: "urn:oid:id-fallback"},
|
||||
}
|
||||
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{fromID, fromIdentifier, idFallback, update})}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertAlertIDs(t, run, []string{"urn:oid:from-id", "urn:oid:update"})
|
||||
}
|
||||
|
||||
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), testTimePtr(12)),
|
||||
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
||||
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15), testTimePtr(15)),
|
||||
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
|
||||
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14), testTimePtr(14)),
|
||||
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14), testTimePtr(14)),
|
||||
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil, nil),
|
||||
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
|
||||
testAlert("ends-preferred", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(14), testTimePtr(11)),
|
||||
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(12), 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, ends *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,
|
||||
Ends: ends,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
templates/alerts_active.txt.tmpl
Normal file
23
templates/alerts_active.txt.tmpl
Normal file
@@ -0,0 +1,23 @@
|
||||
{{- if .Data -}}
|
||||
Active Alerts
|
||||
As Of: {{.Data.AsOf}}
|
||||
Alerts: {{len .Data.Alerts}}
|
||||
{{- range $i, $alert := .Data.Alerts}}
|
||||
|
||||
[{{$i}}] {{$alert.ID}}
|
||||
{{- if $alert.Headline}}
|
||||
Headline: {{$alert.Headline}}
|
||||
{{- end}}
|
||||
{{- if $alert.Severity}}
|
||||
Severity: {{$alert.Severity}}
|
||||
{{- end}}
|
||||
{{- if $alert.Ends}}
|
||||
Ends: {{$alert.Ends}}
|
||||
{{- end}}
|
||||
{{- if $alert.Expires}}
|
||||
Expires: {{$alert.Expires}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No active alerts data available.
|
||||
{{- end}}
|
||||
42
templates/conditions_current.txt.tmpl
Normal file
42
templates/conditions_current.txt.tmpl
Normal file
@@ -0,0 +1,42 @@
|
||||
{{- if .Data -}}
|
||||
Current Conditions
|
||||
Condition Code: {{.Data.ConditionCode}}
|
||||
{{- if .Data.ConditionText}}
|
||||
Condition: {{.Data.ConditionText}}
|
||||
{{- end}}
|
||||
{{- if .Data.IsDayText}}
|
||||
Is Day: {{.Data.IsDayText}}
|
||||
{{- end}}
|
||||
{{- if .Data.TemperatureC}}
|
||||
Temperature (C): {{.Data.TemperatureC}}
|
||||
{{- end}}
|
||||
{{- if .Data.ApparentTemperatureC}}
|
||||
Apparent Temperature (C): {{.Data.ApparentTemperatureC}}
|
||||
{{- end}}
|
||||
{{- if .Data.DewpointC}}
|
||||
Dewpoint (C): {{.Data.DewpointC}}
|
||||
{{- end}}
|
||||
{{- if .Data.WindSpeedKmh}}
|
||||
Wind Speed (km/h): {{.Data.WindSpeedKmh}}
|
||||
{{- end}}
|
||||
{{- if .Data.TemperatureF}}
|
||||
Temperature (F): {{.Data.TemperatureF}}
|
||||
{{- end}}
|
||||
{{- if .Data.ApparentTemperatureF}}
|
||||
Apparent Temperature (F): {{.Data.ApparentTemperatureF}}
|
||||
{{- end}}
|
||||
{{- if .Data.DewpointF}}
|
||||
Dewpoint (F): {{.Data.DewpointF}}
|
||||
{{- end}}
|
||||
{{- if .Data.WindSpeedMph}}
|
||||
Wind Speed (mph): {{.Data.WindSpeedMph}}
|
||||
{{- end}}
|
||||
{{- if .Data.RelativeHumidityPercent}}
|
||||
Relative Humidity (%): {{.Data.RelativeHumidityPercent}}
|
||||
{{- end}}
|
||||
{{- if .Data.WindDirectionDegrees}}
|
||||
Wind Direction (deg): {{.Data.WindDirectionDegrees}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No current conditions data available.
|
||||
{{- end}}
|
||||
43
templates/discussion.txt.tmpl
Normal file
43
templates/discussion.txt.tmpl
Normal file
@@ -0,0 +1,43 @@
|
||||
{{- if .Data -}}
|
||||
Forecast Discussion
|
||||
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
|
||||
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
|
||||
Product: {{.Data.Product}}
|
||||
Issued At: {{.Data.IssuedAt}}
|
||||
{{- if .Data.UpdatedAt}}
|
||||
Updated At: {{.Data.UpdatedAt}}
|
||||
{{- end}}
|
||||
Key Messages: {{len .Data.KeyMessages}}
|
||||
{{- range $i, $message := .Data.KeyMessages}}
|
||||
|
||||
[{{$i}}] {{$message}}
|
||||
{{- end}}
|
||||
{{- if .Data.ShortTerm}}
|
||||
|
||||
Short Term
|
||||
{{- if .Data.ShortTerm.Qualifier}}
|
||||
Qualifier: {{.Data.ShortTerm.Qualifier}}
|
||||
{{- end}}
|
||||
{{- if .Data.ShortTerm.IssuedAt}}
|
||||
Issued At: {{.Data.ShortTerm.IssuedAt}}
|
||||
{{- end}}
|
||||
{{- if .Data.ShortTerm.Text}}
|
||||
Text: {{.Data.ShortTerm.Text}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .Data.LongTerm}}
|
||||
|
||||
Long Term
|
||||
{{- if .Data.LongTerm.Qualifier}}
|
||||
Qualifier: {{.Data.LongTerm.Qualifier}}
|
||||
{{- end}}
|
||||
{{- if .Data.LongTerm.IssuedAt}}
|
||||
Issued At: {{.Data.LongTerm.IssuedAt}}
|
||||
{{- end}}
|
||||
{{- if .Data.LongTerm.Text}}
|
||||
Text: {{.Data.LongTerm.Text}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No forecast discussion data available.
|
||||
{{- end}}
|
||||
17
templates/discussion_key_messages.txt.tmpl
Normal file
17
templates/discussion_key_messages.txt.tmpl
Normal file
@@ -0,0 +1,17 @@
|
||||
{{- if .Data -}}
|
||||
Forecast Discussion Key Messages
|
||||
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
|
||||
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
|
||||
Product: {{.Data.Product}}
|
||||
Issued At: {{.Data.IssuedAt}}
|
||||
{{- if .Data.UpdatedAt}}
|
||||
Updated At: {{.Data.UpdatedAt}}
|
||||
{{- end}}
|
||||
Key Messages: {{len .Data.KeyMessages}}
|
||||
{{- range $i, $message := .Data.KeyMessages}}
|
||||
|
||||
[{{$i}}] {{$message}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No key messages discussion data available.
|
||||
{{- end}}
|
||||
25
templates/discussion_long_term.txt.tmpl
Normal file
25
templates/discussion_long_term.txt.tmpl
Normal file
@@ -0,0 +1,25 @@
|
||||
{{- if .Data -}}
|
||||
Forecast Discussion Long Term
|
||||
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
|
||||
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
|
||||
Product: {{.Data.Product}}
|
||||
Issued At: {{.Data.IssuedAt}}
|
||||
{{- if .Data.UpdatedAt}}
|
||||
Updated At: {{.Data.UpdatedAt}}
|
||||
{{- end}}
|
||||
{{- if .Data.LongTerm}}
|
||||
{{- if .Data.LongTerm.Qualifier}}
|
||||
Qualifier: {{.Data.LongTerm.Qualifier}}
|
||||
{{- end}}
|
||||
{{- if .Data.LongTerm.IssuedAt}}
|
||||
Section Issued At: {{.Data.LongTerm.IssuedAt}}
|
||||
{{- end}}
|
||||
{{- if .Data.LongTerm.Text}}
|
||||
Text: {{.Data.LongTerm.Text}}
|
||||
{{- end}}
|
||||
{{- else}}
|
||||
No long term discussion data available.
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No long term discussion data available.
|
||||
{{- end}}
|
||||
25
templates/discussion_short_term.txt.tmpl
Normal file
25
templates/discussion_short_term.txt.tmpl
Normal file
@@ -0,0 +1,25 @@
|
||||
{{- if .Data -}}
|
||||
Forecast Discussion Short Term
|
||||
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
|
||||
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
|
||||
Product: {{.Data.Product}}
|
||||
Issued At: {{.Data.IssuedAt}}
|
||||
{{- if .Data.UpdatedAt}}
|
||||
Updated At: {{.Data.UpdatedAt}}
|
||||
{{- end}}
|
||||
{{- if .Data.ShortTerm}}
|
||||
{{- if .Data.ShortTerm.Qualifier}}
|
||||
Qualifier: {{.Data.ShortTerm.Qualifier}}
|
||||
{{- end}}
|
||||
{{- if .Data.ShortTerm.IssuedAt}}
|
||||
Section Issued At: {{.Data.ShortTerm.IssuedAt}}
|
||||
{{- end}}
|
||||
{{- if .Data.ShortTerm.Text}}
|
||||
Text: {{.Data.ShortTerm.Text}}
|
||||
{{- end}}
|
||||
{{- else}}
|
||||
No short term discussion data available.
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No short term discussion data available.
|
||||
{{- end}}
|
||||
19
templates/forecast_hourly.txt.tmpl
Normal file
19
templates/forecast_hourly.txt.tmpl
Normal file
@@ -0,0 +1,19 @@
|
||||
{{- if .Data -}}
|
||||
Hourly Forecast
|
||||
Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}}
|
||||
Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}}
|
||||
Issued At: {{.Data.IssuedAt}}
|
||||
Periods: {{len .Data.Periods}}
|
||||
{{- range $i, $period := .Data.Periods}}
|
||||
|
||||
[{{$i}}] {{$period.StartTime}} -> {{$period.EndTime}}
|
||||
{{- if $period.ConditionCode}}
|
||||
Condition Code: {{$period.ConditionCode}}
|
||||
{{- end}}
|
||||
{{- if $period.TextDescription}}
|
||||
Summary: {{$period.TextDescription}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No hourly forecast data available.
|
||||
{{- end}}
|
||||
22
templates/forecast_narrative.txt.tmpl
Normal file
22
templates/forecast_narrative.txt.tmpl
Normal file
@@ -0,0 +1,22 @@
|
||||
{{- if .Data -}}
|
||||
Narrative Forecast
|
||||
Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}}
|
||||
Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}}
|
||||
Issued At: {{.Data.IssuedAt}}
|
||||
Periods: {{len .Data.Periods}}
|
||||
{{- range $i, $period := .Data.Periods}}
|
||||
|
||||
[{{$i}}] {{$period.StartTime}} -> {{$period.EndTime}}
|
||||
{{- if $period.Name}}
|
||||
Name: {{$period.Name}}
|
||||
{{- end}}
|
||||
{{- if $period.ConditionCode}}
|
||||
Condition Code: {{$period.ConditionCode}}
|
||||
{{- end}}
|
||||
{{- if $period.TextDescription}}
|
||||
Summary: {{$period.TextDescription}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No narrative forecast data available.
|
||||
{{- end}}
|
||||
12
templates/observations.txt.tmpl
Normal file
12
templates/observations.txt.tmpl
Normal file
@@ -0,0 +1,12 @@
|
||||
{{- if .Data -}}
|
||||
Observation
|
||||
Station ID: {{if .Data.StationID}}{{.Data.StationID}}{{else}}n/a{{end}}
|
||||
Station Name: {{if .Data.StationName}}{{.Data.StationName}}{{else}}n/a{{end}}
|
||||
Timestamp: {{.Data.Timestamp}}
|
||||
Condition Code: {{.Data.ConditionCode}}
|
||||
{{- if .Data.TextDescription}}
|
||||
Summary: {{.Data.TextDescription}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No observation data available.
|
||||
{{- end}}
|
||||
42
templates/outlooks_convective.txt.tmpl
Normal file
42
templates/outlooks_convective.txt.tmpl
Normal file
@@ -0,0 +1,42 @@
|
||||
{{- if .Data -}}
|
||||
Convective Outlook
|
||||
Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}}
|
||||
Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}}
|
||||
As Of: {{.Data.AsOf}}
|
||||
{{- if .Data.IssuedAt}}
|
||||
Issued At: {{.Data.IssuedAt}}
|
||||
{{- end}}
|
||||
Outlooks: {{len .Data.Outlooks}}
|
||||
{{- range $i, $outlook := .Data.Outlooks}}
|
||||
|
||||
[{{$i}}] Day {{$outlook.Day}} {{$outlook.OutlookType}} {{$outlook.Label}}
|
||||
Valid: {{$outlook.ValidFrom}} -> {{$outlook.ValidTo}}
|
||||
Contains Location: {{$outlook.ContainsLocation}}
|
||||
{{- if $outlook.LabelText}}
|
||||
Label Text: {{$outlook.LabelText}}
|
||||
{{- end}}
|
||||
{{- if $outlook.SourceURL}}
|
||||
Source URL: {{$outlook.SourceURL}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
Discussions: {{len .Data.Discussions}}
|
||||
{{- range $i, $discussion := .Data.Discussions}}
|
||||
|
||||
[{{$i}}] Day {{$discussion.Day}} Discussion
|
||||
{{- if $discussion.UpdatedAt}}
|
||||
Updated At: {{$discussion.UpdatedAt}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Headline}}
|
||||
Headline: {{$discussion.Headline}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Summary}}
|
||||
Summary: {{$discussion.Summary}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Discussion}}
|
||||
Discussion: {{$discussion.Discussion}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No convective outlook data available.
|
||||
{{- end}}
|
||||
26
templates/weatherstories.txt.tmpl
Normal file
26
templates/weatherstories.txt.tmpl
Normal file
@@ -0,0 +1,26 @@
|
||||
{{- if .Data -}}
|
||||
Weather Stories
|
||||
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
|
||||
As Of: {{.Data.AsOf}}
|
||||
Stories: {{len .Data.Stories}}
|
||||
{{- range $i, $story := .Data.Stories}}
|
||||
|
||||
[{{$i}}] {{if $story.Title}}{{$story.Title}}{{else}}Untitled{{end}}
|
||||
Start: {{$story.StartTime}}
|
||||
End: {{$story.EndTime}}
|
||||
Updated: {{$story.UpdatedAt}}
|
||||
Priority: {{$story.Priority}}
|
||||
Order: {{$story.Order}}
|
||||
{{- if $story.Description}}
|
||||
Description: {{$story.Description}}
|
||||
{{- end}}
|
||||
{{- if $story.AltText}}
|
||||
Alt Text: {{$story.AltText}}
|
||||
{{- end}}
|
||||
{{- if $story.DownloadURL}}
|
||||
Download URL: {{$story.DownloadURL}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No weather stories data available.
|
||||
{{- end}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user