diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
new file mode 100644
index 0000000..3d59126
--- /dev/null
+++ b/.github/badges/jacoco.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ac8ae7b..35d614d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,6 +17,9 @@ jobs:
test:
name: Build & Test
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ checks: write
steps:
- name: Checkout code
uses: actions/checkout@v7
@@ -32,19 +35,16 @@ jobs:
run: chmod +x ./mvnw
- name: Run tests
- # Tests run against the in-memory H2 DB and spring.cache.type=simple,
- # so no Postgres/Redis service containers are needed here.
- # WEATHER_API_KEY just needs to be non-empty; tests mock the HTTP client.
env:
WEATHER_API_KEY: dummy-ci-key
- run: ./mvnw -B test
+ run: ./mvnw -B verify
- name: Publish test report
if: always()
uses: dorny/test-reporter@v3
with:
name: Maven Tests
- path: target/surefire-reports/*.xml
+ path: "target/surefire-reports/TEST-*.xml,target/failsafe-reports/TEST-*.xml"
reporter: java-junit
- name: Upload JaCoCo coverage report
@@ -66,7 +66,46 @@ jobs:
path: target/*.jar
# ---------------------------------------------------------------------
- # 2. Build + push a versioned image to Docker Hub
+ # 2. Regenerate the coverage badge from the JaCoCo report and commit it
+ # - runs ONLY on: pushes to main or dev
+ # - the ref check alone excludes pull_request events: their ref is
+ # refs/pull//merge, never refs/heads/main or .../dev
+ # - actions/checkout defaults to the triggering ref, so the commit
+ # lands back on whichever branch (main or dev) was just pushed
+ # - reads target/site/jacoco/jacoco.csv from the `test` job above
+ # ---------------------------------------------------------------------
+ coverage-badge:
+ name: Update Coverage Badge
+ needs: test
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+
+ - name: Download JaCoCo report
+ uses: actions/download-artifact@v7
+ with:
+ name: jacoco-report
+ path: target/site/jacoco
+
+ - name: Generate coverage badge
+ uses: cicirello/jacoco-badge-generator@v2
+ with:
+ jacoco-csv-file: target/site/jacoco/jacoco.csv
+ badges-directory: .github/badges
+ generate-branches-badge: false
+ generate-summary: false
+
+ - name: Commit badge
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: "chore: update coverage badge [skip ci]"
+ file_pattern: .github/badges/*.svg
+
+ # ---------------------------------------------------------------------
+ # 3. Build + push a versioned image to Docker Hub
# - runs ONLY on: pushes to main
# - every push to main -> : :latest
# - pushes to dev, and all PRs -> this job does not run at all
diff --git a/README.md b/README.md
index 17951d2..a44b6b3 100644
--- a/README.md
+++ b/README.md
@@ -10,13 +10,15 @@
-
+
+
Overview ·
Features ·
Tech Stack ·
+ Architecture ·
Getting Started ·
API Docs ·
Observability ·
@@ -74,6 +76,60 @@ WeatherViewer is a personal weather dashboard for tracking the places you care a
| CI/CD | GitHub Actions (build/test, coverage, Docker Hub image push) |
| External API | [OpenWeatherMap](https://openweathermap.org/api) (current weather, forecast, geocoding) |
+## Architecture
+
+**System overview** — the app sits between the browser and four external dependencies. Every HTTP request passes through the rate limiter and the security filter chain before reaching a controller; live dashboard/forecast updates instead flow over a persistent WebSocket connection, pushed on a schedule rather than requested:
+
+```mermaid
+flowchart TB
+ Client[Browser client]
+ RL[Rate limiter]
+ Sec[Security filter chain]
+ Web[Controllers + REST]
+ WS[WebSocket / STOMP]
+ Svc[Services]
+ DB[(PostgreSQL)]
+ Cache[(Redis)]
+ Weather[(OpenWeatherMap API)]
+ Mail[(SMTP)]
+
+ Client --> RL --> Sec --> Web
+ Client -. live updates .-> WS
+ Web --> Svc
+ WS --> Svc
+ Svc --> DB
+ Svc --> Cache
+ Svc --> Weather
+ Svc --> Mail
+```
+
+Postgres holds users, locations, and tokens (schema managed by Liquibase). Redis backs both the rate limiter's fixed-window counters and the weather/forecast/geocoding cache. The two flows below zoom into the parts of this picture that need to tolerate a flaky dependency: weather reads and outbound mail.
+
+Two request paths matter most for reliability: reads that hit the OpenWeatherMap API, and emails triggered by account actions. Both are built so a slow or failing dependency degrades gracefully instead of taking the app down with it.
+
+**Weather read path** — a cache-aside read guarded by retry and a circuit breaker:
+
+```mermaid
+flowchart LR
+ A[Controller] --> B["Cache @Cacheable"]
+ B --> C["Client retry + breaker"]
+ C --> D[("Weather API")]
+ C -. fallback .-> E["Fallback service unavailable"]
+```
+
+A cache hit never reaches `WeatherApiClient`. On a miss, every outbound call is wrapped with Resilience4j: transient failures are retried with backoff, and once OpenWeatherMap is failing consistently the breaker opens and short-circuits straight to the fallback instead of piling up slow requests — so one saved location failing to load doesn't take the rest of the dashboard down with it.
+
+**Async mail path** — a write that only sends mail after its transaction commits:
+
+```mermaid
+flowchart LR
+ F["Service writes token"] --> G["Event after commit"]
+ G --> H["Listener @Async"]
+ H --> I[("SMTP")]
+```
+
+Verification and password-reset emails are sent from a `@TransactionalEventListener(phase = AFTER_COMMIT)`, so an email can never reference a token whose transaction rolled back. The send itself runs `@Async` on a dedicated pool, so a slow SMTP server can't add latency to the request that triggered it. `MailService` retries transient SMTP failures on its own and never throws — a failure there is logged and goes no further.
+
## Prerequisites
- Java 17+
@@ -193,6 +249,30 @@ Actuator runs on a separate management port so it can be kept off the public net
Every log line is tagged with a request correlation ID, and HTTP request latency is exported as a histogram for easy percentile/SLO tracking.
+`docker-compose.yml` also runs a Prometheus + Grafana stack alongside the app, scraping `/actuator/prometheus` every 15s:
+
+```bash
+docker compose up -d
+```
+
+| Service | URL | Notes |
+|:-----------|:------------------------|:---------------------------------------------------------------|
+| Prometheus | http://localhost:9090 | Scrapes `weather_viewer:8081/actuator/prometheus` |
+| Grafana | http://localhost:3000 | Login `admin` / `admin` (dev-only default, see below) |
+
+Grafana auto-provisions the Prometheus datasource and a starter **Weather Viewer — Overview** dashboard on first startup — nothing to click through manually. It covers HTTP request rate/p95 latency, JVM heap usage, the Redis cache hit ratio, and the `weatherApi` circuit breaker state and retry calls (the same Resilience4j instance the [architecture diagrams](#architecture) above describe). Config lives under `monitoring/`:
+
+```
+monitoring/
+├── prometheus/prometheus.yml # scrape target + interval
+└── grafana/
+ ├── provisioning/datasources/datasource.yml # auto-adds Prometheus
+ ├── provisioning/dashboards/dashboards.yml # tells Grafana where to look
+ └── dashboards/weather-viewer-overview.json # the starter dashboard itself
+```
+
+Grafana's admin login comes from `GRAFANA_ADMIN_USER`/`GRAFANA_ADMIN_PASSWORD` in `.env` (same pattern as `POSTGRES_PASSWORD`), falling back to `admin`/`admin` if unset — fine for a quick local run, but set them in `.env` before running this anywhere reachable off your own machine.
+
## Security
- Passwords are hashed with BCrypt; sign-in is protected by per-account lockout after repeated failed attempts
@@ -205,17 +285,21 @@ Every log line is tagged with a request correlation ID, and HTTP request latency
## Running Tests
```bash
-./mvnw test
+./mvnw test # fast, Docker-free: unit tests + @WebMvcTest slices
+./mvnw verify # everything above, plus the *IT integration suite
```
-Tests run against an in-memory H2 database, so no external services are required. The suite includes unit tests, MVC/REST controller tests, repository tests, and full integration tests for auth (including verification, password reset, and remember-me), search, profile, and weather flows. JaCoCo generates a coverage report at `target/site/jacoco/index.html` after running tests.
+Unit tests (model/DTO/enum tests, Mockito-based service tests) and `@WebMvcTest` controller slices don't start a real datasource at all, so `./mvnw test` alone needs nothing but a JDK — no Docker required. Classes named `*IT` (e.g. `UserRepositoryIT`, `SignInIT`) are the ones that boot a full Spring context against real Postgres and Redis via [Testcontainers](https://testcontainers.com/) — `TestcontainersConfiguration` wires both in via `@ServiceConnection`. Maven's Failsafe plugin only runs those during `./mvnw verify`, not `./mvnw test`, so a running Docker daemon is only required for `verify`.
+
+The suite covers unit tests, MVC/REST controller tests, repository tests, and full integration tests for auth (including verification, password reset, and remember-me), search, profile, and weather flows. JaCoCo instruments both Surefire (`test`) and Failsafe (`*IT`) runs separately, then merges the two into one combined report — that merge, and the report itself, only happen as part of `./mvnw verify`, at `target/site/jacoco/index.html`. The 90% line-coverage gate (`jacoco:check`) reads that same merged data and only runs during `verify` as well.
## CI/CD
Every push to `main` and every pull request into `main`/`dev` runs through GitHub Actions:
-1. **Build & Test** — compiles the project and runs the full test suite against H2, publishing a JUnit test report and a JaCoCo coverage report as workflow artifacts.
-2. **Docker build & push** — on pushes to `main`, builds the application image and pushes it to Docker Hub as `podllev/weather-viewer`.
+1. **Build & Test** — runs `./mvnw verify`: unit/slice tests via Surefire plus the `*IT` integration suite via Failsafe (real Postgres/Redis via Testcontainers), publishing a JUnit test report and the merged JaCoCo coverage report as workflow artifacts.
+2. **Update coverage badge** — on pushes to `main` or `dev`, regenerates that branch's `.github/badges/jacoco.svg` badge from the JaCoCo report and commits it back.
+3. **Docker build & push** — on pushes to `main`, builds the application image and pushes it to Docker Hub as `podllev/weather-viewer`.
See `.github/workflows/ci.yml` for the full pipeline.
diff --git a/docker-compose.yml b/docker-compose.yml
index 0d395dc..7d1839a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,10 +1,10 @@
services:
- weather_viewer:
+ weather-viewer:
build:
context: .
dockerfile: Dockerfile
image: podllev/weather-viewer:latest
- container_name: weather_viewer
+ container_name: weather-viewer
restart: always
ports:
- "8080:8080"
@@ -28,7 +28,7 @@ services:
postgres:
image: postgres:17
- container_name: weather_viewer_db
+ container_name: weather-viewer-db
restart: always
env_file:
- .env
@@ -47,7 +47,7 @@ services:
redis:
image: redis:7
- container_name: weather_viewer_redis
+ container_name: weather-viewer-redis
restart: always
ports:
- "6379:6379"
@@ -59,6 +59,46 @@ services:
timeout: 5s
retries: 5
+ prometheus:
+ image: prom/prometheus:v3.8.1
+ container_name: weather-viewer-prometheus
+ restart: always
+ volumes:
+ - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus_data:/prometheus
+ ports:
+ - "9090:9090"
+ healthcheck:
+ test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/ready" ]
+ interval: 15s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ depends_on:
+ weather-viewer:
+ condition: service_healthy
+
+ grafana:
+ image: grafana/grafana:13.1.1
+ container_name: weather-viewer-grafana
+ restart: always
+ env_file:
+ - .env
+ environment:
+ - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin}
+ - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
+ - GF_AUTH_ANONYMOUS_ENABLED=false
+ volumes:
+ - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
+ - grafana_data:/var/lib/grafana
+ ports:
+ - "3000:3000"
+ depends_on:
+ - prometheus
+
volumes:
postgres_data:
redis_data:
+ prometheus_data:
+ grafana_data:
\ No newline at end of file
diff --git a/monitoring/grafana/dashboards/weather-viewer-overview.json b/monitoring/grafana/dashboards/weather-viewer-overview.json
new file mode 100644
index 0000000..c5617b3
--- /dev/null
+++ b/monitoring/grafana/dashboards/weather-viewer-overview.json
@@ -0,0 +1,102 @@
+{
+ "title": "Weather Viewer — Overview",
+ "uid": "weather-viewer-overview",
+ "schemaVersion": 39,
+ "version": 1,
+ "editable": true,
+ "timezone": "browser",
+ "refresh": "10s",
+ "time": { "from": "now-1h", "to": "now" },
+ "tags": ["weather-viewer"],
+ "panels": [
+ {
+ "id": 1,
+ "title": "HTTP request rate",
+ "type": "timeseries",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "targets": [
+ {
+ "expr": "sum(rate(http_server_requests_seconds_count[1m])) by (uri, status)",
+ "legendFormat": "{{uri}} [{{status}}]"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
+ },
+ {
+ "id": 2,
+ "title": "HTTP p95 latency",
+ "type": "timeseries",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))",
+ "legendFormat": "{{uri}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ },
+ {
+ "id": 3,
+ "title": "JVM heap used",
+ "type": "timeseries",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "targets": [
+ {
+ "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) by (id)",
+ "legendFormat": "{{id}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }
+ },
+ {
+ "id": 4,
+ "title": "Cache hit ratio",
+ "type": "timeseries",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "description": "Requires spring.cache.type=redis — the simple in-memory cache profile doesn't emit cache.gets metrics.",
+ "targets": [
+ {
+ "expr": "sum(rate(cache_gets_total{result=\"hit\"}[5m])) by (cache) / sum(rate(cache_gets_total[5m])) by (cache)",
+ "legendFormat": "{{cache}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": { "unit": "percentunit", "min": 0, "max": 1 },
+ "overrides": []
+ }
+ },
+ {
+ "id": 5,
+ "title": "weatherApi circuit breaker state",
+ "type": "state-timeline",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
+ "description": "0 = closed, 1 = open, 2 = half-open.",
+ "targets": [
+ {
+ "expr": "resilience4j_circuitbreaker_state{name=\"weatherApi\"}",
+ "legendFormat": "{{state}}"
+ }
+ ]
+ },
+ {
+ "id": 6,
+ "title": "weatherApi retry calls",
+ "type": "timeseries",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
+ "description": "kind: successful_without_retry / successful_with_retry / failed_with_retry / failed_without_retry",
+ "targets": [
+ {
+ "expr": "sum(rate(resilience4j_retry_calls_total{name=\"weatherApi\"}[5m])) by (kind)",
+ "legendFormat": "{{kind}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
+ }
+ ]
+}
diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml
new file mode 100644
index 0000000..3efe969
--- /dev/null
+++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml
@@ -0,0 +1,12 @@
+apiVersion: 1
+
+providers:
+ - name: WeatherViewer
+ orgId: 1
+ folder: ""
+ type: file
+ disableDeletion: false
+ updateIntervalSeconds: 30
+ allowUiUpdates: true
+ options:
+ path: /var/lib/grafana/dashboards
diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml
new file mode 100644
index 0000000..00f9915
--- /dev/null
+++ b/monitoring/grafana/provisioning/datasources/datasource.yml
@@ -0,0 +1,10 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ uid: prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ editable: false
diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml
new file mode 100644
index 0000000..bc65d76
--- /dev/null
+++ b/monitoring/prometheus/prometheus.yml
@@ -0,0 +1,9 @@
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+scrape_configs:
+ - job_name: weather-viewer
+ metrics_path: /actuator/prometheus
+ static_configs:
+ - targets: ["weather-viewer:8081"]
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 2928ac7..7ce0f90 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,9 +10,9 @@
comWeatherViewer
- 1.2.0
+ 1.3.0WeatherViewer
- A Spring Boot application for checking weather forecasts, managing favorite locations, and tracking request metrics with built-in rate limiting. Supports English and Ukrainian locales.
+ A Spring Boot application for checking weather forecasts on an interactive map with live WebSocket updates, managing favorite locations, and tracking request metrics with built-in rate limiting and Prometheus/Grafana observability. Supports English and Ukrainian locales.
@@ -53,6 +53,14 @@
org.springframework.bootspring-boot-starter-web
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
+
+ io.projectreactor
+ reactor-core
+ org.springframework.bootspring-boot-starter-data-jpa
@@ -158,8 +166,18 @@
4.0.0
- com.h2database
- h2
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ postgresqltest
@@ -217,12 +235,42 @@
prepare-agent
+
+ prepare-agent-integration
+
+ prepare-agent-integration
+
+
+ failsafeArgLine
+
+
+
+ merge-results
+ post-integration-test
+
+ merge
+
+
+
+
+ ${project.build.directory}
+
+ *.exec
+
+
+
+ ${project.build.directory}/jacoco-merged.exec
+
+ report
- test
+ post-integration-testreport
+
+ ${project.build.directory}/jacoco-merged.exec
+ check
@@ -230,6 +278,7 @@
check
+ ${project.build.directory}/jacoco-merged.execBUNDLE
@@ -246,6 +295,22 @@
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+ ${failsafeArgLine}
+
+
+
+
+ integration-test
+ verify
+
+
+
+ org.apache.maven.pluginsmaven-javadoc-plugin
diff --git a/src/main/java/com/weatherviewer/WeatherViewerApplication.java b/src/main/java/com/weatherviewer/WeatherViewerApplication.java
index 79f5158..6cfc2fd 100644
--- a/src/main/java/com/weatherviewer/WeatherViewerApplication.java
+++ b/src/main/java/com/weatherviewer/WeatherViewerApplication.java
@@ -4,6 +4,7 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;
@@ -13,14 +14,17 @@
* Bootstraps the application context and enables Spring's caching
* abstraction ({@link EnableCaching}), which backs the weather/forecast/
* geocoding response caching in
- * {@link com.weatherviewer.service.integration.WeatherApiCache}, and
- * Spring's {@code @Async} support ({@link EnableAsync}), which backs
- * asynchronous mail dispatch in
- * {@link com.weatherviewer.service.impl.MailEventListener}.
+ * {@link com.weatherviewer.service.integration.WeatherApiCache}; Spring's
+ * {@code @Async} support ({@link EnableAsync}), which backs asynchronous
+ * mail dispatch in {@link com.weatherviewer.service.impl.MailEventListener};
+ * and {@code @Scheduled} support ({@link EnableScheduling}), which drives
+ * the periodic live weather broadcast in
+ * {@link com.weatherviewer.websocket.WeatherLiveUpdateScheduler}.
*/
@SpringBootApplication
@EnableCaching
@EnableAsync
+@EnableScheduling
public class WeatherViewerApplication {
/**
diff --git a/src/main/java/com/weatherviewer/config/CacheConfig.java b/src/main/java/com/weatherviewer/config/CacheConfig.java
new file mode 100644
index 0000000..1cad815
--- /dev/null
+++ b/src/main/java/com/weatherviewer/config/CacheConfig.java
@@ -0,0 +1,21 @@
+package com.weatherviewer.config;
+
+import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Set;
+
+@Configuration
+@EnableCaching
+public class CacheConfig {
+
+ @Bean
+ public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
+ return builder -> builder
+ .enableStatistics()
+ .initialCacheNames(Set.of("weatherCache", "forecastCache", "geoCache"));
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java
index 39530b6..a6c4751 100644
--- a/src/main/java/com/weatherviewer/config/SecurityConfig.java
+++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java
@@ -80,7 +80,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/verify-email", "/resend-verification",
"/forgot-password", "/reset-password",
"/css/**", "/images/**", "/js/**",
- "/actuator/health", "/actuator/health/**"
+ "/actuator", "/actuator/**"
).permitAll()
.requestMatchers(
"/swagger-ui.html", "/swagger-ui/**",
@@ -121,10 +121,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
- "script-src 'self' https://cdn.jsdelivr.net; " +
- "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " +
+ "script-src 'self' https://cdn.jsdelivr.net https://unpkg.com; " +
+ "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " +
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://use.fontawesome.com https://cdnjs.cloudflare.com; " +
- "connect-src 'self' https://cdn.jsdelivr.net; " +
+ "connect-src 'self' https://cdn.jsdelivr.net https://unpkg.com; " +
"img-src 'self' data: https:;"
)
)
diff --git a/src/main/java/com/weatherviewer/config/WebSocketConfig.java b/src/main/java/com/weatherviewer/config/WebSocketConfig.java
new file mode 100644
index 0000000..587eeab
--- /dev/null
+++ b/src/main/java/com/weatherviewer/config/WebSocketConfig.java
@@ -0,0 +1,79 @@
+package com.weatherviewer.config;
+
+import com.weatherviewer.websocket.WeatherSocketController;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.lang.NonNull;
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.socket.WebSocketHandler;
+import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
+import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
+import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
+import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
+
+import java.security.Principal;
+import java.util.Map;
+
+/**
+ * Wires up STOMP-over-WebSocket messaging for live weather updates.
+ *
+ * The app stays on plain (non-SockJS) WebSocket: every browser this app
+ * targets supports it natively, and skipping SockJS avoids its XHR-polling
+ * fallback transports, which are same-origin POSTs that would otherwise
+ * need a CSRF-exemption carve-out in {@link com.weatherviewer.config.SecurityConfig}.
+ * The handshake itself is a plain {@code GET} on {@code /ws} and is subject
+ * to the app's normal {@code anyRequest().authenticated()} rule, so only a
+ * signed-in session can open the socket in the first place.
+ *
+ * Destination layout:
+ *
+ *
{@code /app/**} - client-to-server, handled by {@link WeatherSocketController}
+ *
{@code /user/queue/dashboard} - server-to-client, one user's dashboard weather
+ *
{@code /user/queue/forecast} - server-to-client, one user's forecast-page weather
+ *
+ * Both queues are per-user (not broadcast topics): weather is unit-converted
+ * per viewer and dashboard contents are private to their owner, so a shared
+ * {@code /topic/**} broadcast isn't the right shape here.
+ */
+@Configuration
+@EnableWebSocketMessageBroker
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ registry.addEndpoint("/ws")
+ .setHandshakeHandler(new AuthenticationHandshakeHandler());
+ }
+
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry registry) {
+ registry.enableSimpleBroker("/queue");
+ registry.setApplicationDestinationPrefixes("/app");
+ registry.setUserDestinationPrefix("/user");
+ }
+
+ /**
+ * Copies the {@link Authentication} already established for the
+ * handshake HTTP request (loaded from the session by Spring Security's
+ * filter chain, same as any other authenticated request) onto the
+ * WebSocket session as its {@link Principal}. Without this, every STOMP
+ * session would be anonymous and {@code convertAndSendToUser} would have
+ * no username to route on.
+ */
+ static class AuthenticationHandshakeHandler extends DefaultHandshakeHandler {
+
+ @Override
+ protected Principal determineUser(@NonNull ServerHttpRequest request,
+ @NonNull WebSocketHandler wsHandler,
+ @NonNull Map attributes) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !authentication.isAuthenticated()) {
+ return null;
+ }
+ return authentication;
+ }
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/controller/ForecastController.java b/src/main/java/com/weatherviewer/controller/ForecastController.java
index c8cae59..7330643 100644
--- a/src/main/java/com/weatherviewer/controller/ForecastController.java
+++ b/src/main/java/com/weatherviewer/controller/ForecastController.java
@@ -1,6 +1,8 @@
package com.weatherviewer.controller;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.weatherviewer.dto.WeatherDto;
+import com.weatherviewer.dto.enums.WeatherCondition;
import com.weatherviewer.security.SecUser;
import com.weatherviewer.service.LocationService;
import com.weatherviewer.service.WeatherApiService;
@@ -9,13 +11,17 @@
import com.weatherviewer.validation.annotation.Longitude;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.MessageSource;
+import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
/**
* Thymeleaf controller for the hourly/daily forecast page of one of the
@@ -29,6 +35,8 @@ public class ForecastController {
private final WeatherApiService weatherApiService;
private final LocationService locationService;
private final UnitConverter unitConverter;
+ private final MessageSource messageSource;
+ private final ObjectMapper objectMapper;
/**
* Renders the forecast page for the saved location at the given
@@ -51,6 +59,8 @@ public String getForecast(@RequestParam("lat") @Latitude double latitude,
log.info("Forecast retrieved for location={} (user={})", locationName, user.getUsername());
+ model.addAttribute("latitude", latitude);
+ model.addAttribute("longitude", longitude);
model.addAttribute("locationName", locationName);
model.addAttribute("hourlyForecast", hourlyForecast);
model.addAttribute("dailyForecast", dailyForecast);
@@ -58,8 +68,30 @@ public String getForecast(@RequestParam("lat") @Latitude double latitude,
model.addAttribute("login", user.getFullName());
model.addAttribute("temperatureSymbol", unitConverter.temperatureSymbol(user.getUnits()));
model.addAttribute("windSpeedUnit", unitConverter.windSpeedUnit(user.getUnits()));
+ model.addAttribute("conditionLabelsJson", buildConditionLabelsJson());
return "forecast";
}
+ /**
+ * Maps every {@link WeatherCondition} to its localized {@code weather-condition.*}
+ * label, serialized as JSON, so {@code live-forecast.js} can translate the raw
+ * enum values pushed over the socket without duplicating
+ * {@code messages.properties} in JavaScript.
+ */
+ private String buildConditionLabelsJson() {
+ Map labels = new LinkedHashMap<>();
+ for (WeatherCondition condition : WeatherCondition.values()) {
+ labels.put(condition.name(),
+ messageSource.getMessage("weather-condition." + condition.name(), null, LocaleContextHolder.getLocale()));
+ }
+
+ try {
+ return objectMapper.writeValueAsString(labels).replace("", "<\\/");
+ } catch (Exception e) {
+ log.warn("Failed to serialize weather condition labels to JSON", e);
+ return "{}";
+ }
+ }
+
}
diff --git a/src/main/java/com/weatherviewer/controller/MapController.java b/src/main/java/com/weatherviewer/controller/MapController.java
new file mode 100644
index 0000000..e35307f
--- /dev/null
+++ b/src/main/java/com/weatherviewer/controller/MapController.java
@@ -0,0 +1,84 @@
+package com.weatherviewer.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.weatherviewer.dto.LocationDto;
+import com.weatherviewer.rest.MapTileController;
+import com.weatherviewer.security.SecUser;
+import com.weatherviewer.service.LocationService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.MessageSource;
+import org.springframework.context.i18n.LocaleContextHolder;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Renders the {@code /map} page: a Leaflet world map with a marker for
+ * each of the current user's saved locations, a toggleable OpenWeatherMap
+ * overlay (precipitation/clouds/temperature/wind) proxied through
+ * {@link MapTileController} so the OpenWeatherMap API key never reaches
+ * the browser, and a click-to-add flow that lets the user save a new
+ * location by clicking a spot on the map (submitted to the existing
+ * {@code /search/add} endpoint).
+ */
+@Controller
+@RequiredArgsConstructor
+@Slf4j
+public class MapController {
+
+ private final LocationService locationService;
+ private final MessageSource messageSource;
+ private final ObjectMapper objectMapper;
+
+ @GetMapping("/map")
+ public String map(Model model, @AuthenticationPrincipal SecUser user, HttpServletRequest request) {
+ List locations = locationService.getByUserId(user.getId());
+ log.info("Map page requested by user '{}', {} saved location(s)", user.getUsername(), locations.size());
+
+ CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
+
+ model.addAttribute("login", user.getFullName());
+ model.addAttribute("locations", locations);
+ model.addAttribute("mapDataJson", buildMapDataJson(locations, csrfToken));
+ return "map";
+ }
+
+ private String buildMapDataJson(List locations, CsrfToken csrfToken) {
+ Map layerLabels = new LinkedHashMap<>();
+ layerLabels.put("precipitation", messageSource.getMessage("map.layer.precipitation", null, LocaleContextHolder.getLocale()));
+ layerLabels.put("clouds", messageSource.getMessage("map.layer.clouds", null, LocaleContextHolder.getLocale()));
+ layerLabels.put("temperature", messageSource.getMessage("map.layer.temperature", null, LocaleContextHolder.getLocale()));
+ layerLabels.put("wind", messageSource.getMessage("map.layer.wind", null, LocaleContextHolder.getLocale()));
+
+ Map labels = new LinkedHashMap<>();
+ labels.put("forecast", messageSource.getMessage("map.popup.forecast", null, LocaleContextHolder.getLocale()));
+ labels.put("addLocation", messageSource.getMessage("map.popup.add-location", null, LocaleContextHolder.getLocale()));
+ labels.put("locationNamePlaceholder", messageSource.getMessage("map.popup.location-name-placeholder", null, LocaleContextHolder.getLocale()));
+
+ Map data = new LinkedHashMap<>();
+ data.put("locations", locations);
+ data.put("layerLabels", layerLabels);
+ data.put("labels", labels);
+ data.put("forecastUrl", "/forecast");
+ data.put("addLocationUrl", "/search/add");
+
+ data.put("csrfParam", csrfToken.getParameterName());
+ data.put("csrfToken", csrfToken.getToken());
+
+ try {
+ return objectMapper.writeValueAsString(data).replace("", "<\\/");
+ } catch (Exception e) {
+ log.warn("Failed to serialize map data to JSON", e);
+ return "{\"locations\":[],\"layerLabels\":{}}";
+ }
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/controller/SearchController.java b/src/main/java/com/weatherviewer/controller/SearchController.java
index 1c4fc5d..356d396 100644
--- a/src/main/java/com/weatherviewer/controller/SearchController.java
+++ b/src/main/java/com/weatherviewer/controller/SearchController.java
@@ -5,6 +5,7 @@
import com.weatherviewer.security.SecUser;
import com.weatherviewer.service.LocationService;
import com.weatherviewer.service.WeatherApiService;
+import com.weatherviewer.utils.SafeRedirectUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -55,19 +56,29 @@ public String searchResults(@RequestParam("q") String query, Model model,
}
/**
- * Saves a chosen search result as a new location for the signed-in
- * user. The submitted {@code userId} is always overwritten with the
- * caller's own ID before validation, so a location can only ever be
- * added to the signer's own account. On validation failure (blank
- * name, out-of-range coordinates, or a duplicate name/coordinates for
- * this user), the errors are flashed and the request redirects back to
- * the dashboard without saving anything.
+ * Saves a chosen search result (or a point picked directly on the
+ * {@code /map} page) as a new location for the signed-in user. The
+ * submitted {@code userId} is always overwritten with the caller's own
+ * ID before validation, so a location can only ever be added to the
+ * signer's own account. On validation failure (blank name,
+ * out-of-range coordinates, or a duplicate name/coordinates for this
+ * user), the errors are flashed and the request redirects back without
+ * saving anything.
+ *
+ * {@code redirectTo} lets a caller (e.g. the map page's "add location"
+ * form) choose where the request lands afterward instead of always
+ * going to the dashboard; it's validated by
+ * {@link SafeRedirectUtils} and falls back to {@code /} if missing or
+ * unsafe, so it can't be used to redirect off-site.
*/
@PostMapping("/search/add")
public String addLocation(@ModelAttribute("addLocation") AddLocationDto addLocationDto,
+ @RequestParam(value = "redirectTo", required = false) String redirectTo,
@AuthenticationPrincipal SecUser secUser,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
+ String target = SafeRedirectUtils.sanitize(redirectTo, "/");
+
addLocationDto.setUserId(secUser.getId());
validator.validate(addLocationDto, bindingResult);
@@ -78,13 +89,13 @@ public String addLocation(@ModelAttribute("addLocation") AddLocationDto addLocat
log.info("Failed to add location for user '{}': {}", secUser.getUsername(), errorMessages);
redirectAttributes.addFlashAttribute("errorMessages", errorMessages);
- return "redirect:/";
+ return "redirect:" + target;
}
locationService.add(addLocationDto);
log.info("Location '{}' added successfully for user '{}'", addLocationDto.getName(), secUser.getUsername());
redirectAttributes.addFlashAttribute("successMessage", "Location added successfully!");
- return "redirect:/";
+ return "redirect:" + target;
}
}
diff --git a/src/main/java/com/weatherviewer/dto/ws/DashboardLocationWeather.java b/src/main/java/com/weatherviewer/dto/ws/DashboardLocationWeather.java
new file mode 100644
index 0000000..7032bae
--- /dev/null
+++ b/src/main/java/com/weatherviewer/dto/ws/DashboardLocationWeather.java
@@ -0,0 +1,22 @@
+package com.weatherviewer.dto.ws;
+
+import com.weatherviewer.dto.WeatherDto;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.util.UUID;
+
+/** Current weather for one saved location, as pushed to a live dashboard subscriber. */
+@Getter
+@Setter
+@NoArgsConstructor
+@Accessors(chain = true)
+public class DashboardLocationWeather {
+
+ private UUID locationId;
+ private String locationName;
+ private WeatherDto weather;
+
+}
diff --git a/src/main/java/com/weatherviewer/dto/ws/DashboardSubscribeRequest.java b/src/main/java/com/weatherviewer/dto/ws/DashboardSubscribeRequest.java
new file mode 100644
index 0000000..ffa94bc
--- /dev/null
+++ b/src/main/java/com/weatherviewer/dto/ws/DashboardSubscribeRequest.java
@@ -0,0 +1,16 @@
+package com.weatherviewer.dto.ws;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/** Sent by the client (STOMP SEND to {@code /app/dashboard.subscribe}) after connecting, or whenever it re-sorts/re-pages the dashboard in place. */
+@Getter
+@Setter
+@NoArgsConstructor
+public class DashboardSubscribeRequest {
+
+ private String sort;
+ private Integer page;
+
+}
diff --git a/src/main/java/com/weatherviewer/dto/ws/DashboardUpdateMessage.java b/src/main/java/com/weatherviewer/dto/ws/DashboardUpdateMessage.java
new file mode 100644
index 0000000..4fdb207
--- /dev/null
+++ b/src/main/java/com/weatherviewer/dto/ws/DashboardUpdateMessage.java
@@ -0,0 +1,26 @@
+package com.weatherviewer.dto.ws;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.util.List;
+
+/**
+ * One live-update tick for a dashboard subscriber: weather for every
+ * location on their currently-viewed page, plus the names of any locations
+ * whose fetch failed this tick (mirrors {@code HomeController}'s
+ * {@code errorMessages} model attribute, so the client can render the same
+ * "temporarily unavailable" state it would get from a full page reload).
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+@Accessors(chain = true)
+public class DashboardUpdateMessage {
+
+ private List locations;
+ private List unavailableLocationNames;
+
+}
diff --git a/src/main/java/com/weatherviewer/dto/ws/ForecastSubscribeRequest.java b/src/main/java/com/weatherviewer/dto/ws/ForecastSubscribeRequest.java
new file mode 100644
index 0000000..3588616
--- /dev/null
+++ b/src/main/java/com/weatherviewer/dto/ws/ForecastSubscribeRequest.java
@@ -0,0 +1,16 @@
+package com.weatherviewer.dto.ws;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/** Sent by the client (STOMP SEND to {@code /app/forecast.subscribe}) after connecting to the forecast page. */
+@Getter
+@Setter
+@NoArgsConstructor
+public class ForecastSubscribeRequest {
+
+ private Double lat;
+ private Double lon;
+
+}
diff --git a/src/main/java/com/weatherviewer/dto/ws/ForecastUpdateMessage.java b/src/main/java/com/weatherviewer/dto/ws/ForecastUpdateMessage.java
new file mode 100644
index 0000000..2f44ab4
--- /dev/null
+++ b/src/main/java/com/weatherviewer/dto/ws/ForecastUpdateMessage.java
@@ -0,0 +1,21 @@
+package com.weatherviewer.dto.ws;
+
+import com.weatherviewer.dto.WeatherDto;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.util.List;
+
+/** One live-update tick for a forecast-page subscriber: refreshed hourly and daily forecast entries. */
+@Getter
+@Setter
+@NoArgsConstructor
+@Accessors(chain = true)
+public class ForecastUpdateMessage {
+
+ private List hourlyForecast;
+ private List dailyForecast;
+
+}
diff --git a/src/main/java/com/weatherviewer/rest/MapTileController.java b/src/main/java/com/weatherviewer/rest/MapTileController.java
new file mode 100644
index 0000000..6205866
--- /dev/null
+++ b/src/main/java/com/weatherviewer/rest/MapTileController.java
@@ -0,0 +1,53 @@
+package com.weatherviewer.rest;
+
+import com.weatherviewer.service.integration.MapTileLayer;
+import com.weatherviewer.service.integration.WeatherTileClient;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.CacheControl;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * Proxies OpenWeatherMap's map-tile endpoints for the {@code /map} page.
+ *
+ * Without this, {@code map.html} would have to request tiles directly
+ * from OpenWeatherMap in the browser, which means putting the API key
+ * (OWM's tile API is authenticated via an {@code appid} query parameter,
+ * same as every other OWM endpoint this app calls) directly into a URL
+ * visible in the page's network requests. Routing through here instead
+ * keeps the key server-side, same as every other OpenWeatherMap call in
+ * this application.
+ *
+ * {@code layer} is resolved against {@link MapTileLayer}'s whitelist
+ * before anything is fetched — an unrecognized value is rejected as a bad
+ * request rather than passed through to OpenWeatherMap.
+ */
+@RestController
+@RequiredArgsConstructor
+public class MapTileController {
+
+ private final WeatherTileClient weatherTileClient;
+
+ @GetMapping(value = "/map/tiles/{layer}/{z}/{x}/{y}", produces = MediaType.IMAGE_PNG_VALUE)
+ public ResponseEntity tile(@PathVariable String layer,
+ @PathVariable int z,
+ @PathVariable int x,
+ @PathVariable int y) {
+ Optional resolvedLayer = MapTileLayer.fromRequestValue(layer);
+ if (resolvedLayer.isEmpty()) {
+ return ResponseEntity.badRequest().build();
+ }
+
+ byte[] tile = weatherTileClient.fetchTile(resolvedLayer.get(), z, x, y);
+ return ResponseEntity.ok()
+ .cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic())
+ .body(tile);
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java b/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java
new file mode 100644
index 0000000..236f1f1
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java
@@ -0,0 +1,42 @@
+package com.weatherviewer.service.integration;
+
+import com.weatherviewer.rest.MapTileController;
+import lombok.Getter;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+/**
+ * Whitelist of OpenWeatherMap map-tile overlays exposed via
+ * {@link MapTileController} for the
+ * {@code /map} page.
+ *
+ * The enum name (lowercased) is what {@code map.html} requests in the tile
+ * URL path; {@link #getOwmCode()} is OpenWeatherMap's own layer identifier,
+ * used only when building the upstream request. Resolving through this
+ * whitelist — rather than passing whatever path segment the client sent
+ * straight through to OpenWeatherMap — keeps the proxy from being usable
+ * to reach arbitrary OpenWeatherMap tile endpoints.
+ */
+@Getter
+public enum MapTileLayer {
+
+ PRECIPITATION("precipitation_new"),
+ CLOUDS("clouds_new"),
+ TEMPERATURE("temp_new"),
+ WIND("wind_new");
+
+ private final String owmCode;
+
+ MapTileLayer(String owmCode) {
+ this.owmCode = owmCode;
+ }
+
+ /** Resolves a request-path layer segment (case-insensitive) to a whitelisted layer, or empty if it doesn't match one. */
+ public static Optional fromRequestValue(String value) {
+ return Arrays.stream(values())
+ .filter(layer -> layer.name().equalsIgnoreCase(value))
+ .findFirst();
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java b/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java
new file mode 100644
index 0000000..2413e9e
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java
@@ -0,0 +1,88 @@
+package com.weatherviewer.service.integration;
+
+import com.weatherviewer.exception.ExternalHttpCallException;
+import com.weatherviewer.rest.MapTileController;
+import io.github.resilience4j.retry.annotation.Retry;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientResponseException;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.net.URI;
+import java.util.regex.Pattern;
+
+/**
+ * Thin HTTP client for OpenWeatherMap's map-tile endpoints (precipitation,
+ * clouds, temperature, and wind overlays for the {@code /map} page), used
+ * by {@link MapTileController}.
+ *
+ * Deliberately kept separate from {@link WeatherApiClient}: tiles are a
+ * different traffic shape (a single pan/zoom can request dozens at once),
+ * non-critical (a missing tile just doesn't render, unlike a failed
+ * weather lookup), and cacheable for a long time — not worth a circuit
+ * breaker of its own, so this only gets a lightweight retry plus a cache.
+ *
+ * Unlike {@link WeatherApiClient}/{@link WeatherApiCache}, retry and
+ * caching live on the same method here rather than being split across two
+ * classes — safe because both are still applied via an external Spring
+ * proxy call (from {@code MapTileController}), so there's no self-invocation
+ * concern, and one client/cache method is simple enough not to need the
+ * extra separation.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class WeatherTileClient {
+
+ private static final Pattern APPID_PATTERN = Pattern.compile("(?i)([?&]appid=)[^&]*");
+
+ private final RestClient restClient;
+
+ @Value("${weather.api.key}")
+ private String apiKey;
+
+ @Value("${weather.tile.base.url}")
+ private String tileBaseUrl;
+
+ /**
+ * Fetches a single PNG tile for the given layer/zoom/coordinates.
+ * Cached by {@code mapTileCache} (see {@code application.properties}
+ * for its TTL) since the same tile is requested repeatedly across
+ * users and across pans/zooms that revisit the same area.
+ */
+ @Retry(name = "mapTile")
+ @Cacheable("mapTileCache")
+ public byte[] fetchTile(MapTileLayer layer, int z, int x, int y) {
+ String url = UriComponentsBuilder
+ .fromUri(URI.create(tileBaseUrl + "/" + layer.getOwmCode() + "/" + z + "/" + x + "/" + y + ".png"))
+ .queryParam("appid", apiKey)
+ .build()
+ .toUriString();
+
+ try {
+ byte[] tile = restClient.get()
+ .uri(URI.create(url))
+ .retrieve()
+ .body(byte[].class);
+ log.debug("Fetched map tile layer={} z={} x={} y={}", layer, z, x, y);
+ return tile;
+ } catch (RestClientResponseException e) {
+ log.warn("Map tile API returned {} for URL: {}", e.getStatusCode(), maskApiKey(url));
+ boolean retryable = e.getStatusCode() == null || !e.getStatusCode().is4xxClientError();
+ throw new ExternalHttpCallException("Map tile API error: " + e.getStatusCode(), retryable);
+ } catch (Exception e) {
+ log.warn("Map tile fetch failed due to network or connection issues for URL: {}", maskApiKey(url));
+ throw new ExternalHttpCallException("Map tile fetch failed due to network or connection issues");
+ }
+ }
+
+ /** Masks the {@code appid} query parameter so the API key never reaches application logs. */
+ private static String maskApiKey(String url) {
+ return APPID_PATTERN.matcher(url).replaceAll("$1***");
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java b/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java
new file mode 100644
index 0000000..52b6696
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/DashboardSubscription.java
@@ -0,0 +1,23 @@
+package com.weatherviewer.websocket;
+
+import com.weatherviewer.model.enums.UnitSystem;
+
+import java.util.UUID;
+
+/**
+ * One client's live-dashboard subscription: which user, in which unit
+ * system, viewing which sorted/paginated slice of their saved locations.
+ * Mirrors the parameters {@code HomeController#home} takes from the query
+ * string, so the scheduler can recompute exactly the same page.
+ *
+ * @param sessionId the STOMP session that registered this subscription
+ * @param userId owner of the dashboard
+ * @param username owner's username (email) - the STOMP user-destination principal name
+ * @param units owner's preferred display units, applied to pushed weather
+ * @param sort dashboard sort key ({@code date}, {@code nameAsc}, {@code nameDesc}, {@code favoriteFirst}, {@code favoritesOnly})
+ * @param page 0-based dashboard page number
+ */
+public record DashboardSubscription(String sessionId, UUID userId,
+ String username, UnitSystem units,
+ String sort, int page) {
+}
diff --git a/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java b/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java
new file mode 100644
index 0000000..1d1ff90
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/ForecastSubscription.java
@@ -0,0 +1,23 @@
+package com.weatherviewer.websocket;
+
+import com.weatherviewer.model.enums.UnitSystem;
+
+import java.util.UUID;
+
+/**
+ * One client's live-forecast subscription: which user, in which unit
+ * system, watching the hourly/daily forecast for a single coordinate pair.
+ * Mirrors the parameters {@code ForecastController#getForecast} takes from
+ * the query string.
+ *
+ * @param sessionId the STOMP session that registered this subscription
+ * @param userId owner of the forecast page (used to re-verify the location is still theirs)
+ * @param username owner's username (email) - the STOMP user-destination principal name
+ * @param units owner's preferred display units, applied to pushed weather
+ * @param latitude location latitude
+ * @param longitude location longitude
+ */
+public record ForecastSubscription(String sessionId, UUID userId,
+ String username, UnitSystem units,
+ double latitude, double longitude) {
+}
diff --git a/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java b/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java
new file mode 100644
index 0000000..493fa7b
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/WeatherLiveUpdateScheduler.java
@@ -0,0 +1,150 @@
+package com.weatherviewer.websocket;
+
+import com.weatherviewer.dto.LocationDto;
+import com.weatherviewer.dto.WeatherDto;
+import com.weatherviewer.dto.ws.DashboardLocationWeather;
+import com.weatherviewer.dto.ws.DashboardUpdateMessage;
+import com.weatherviewer.dto.ws.ForecastUpdateMessage;
+import com.weatherviewer.exception.notfound.LocationNotFoundException;
+import com.weatherviewer.service.LocationService;
+import com.weatherviewer.service.WeatherApiService;
+import com.weatherviewer.service.helper.UnitConverter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Drives every connected client's live weather feed.
+ *
+ * On a fixed schedule, this re-derives exactly what each active
+ * {@link DashboardSubscription}/{@link ForecastSubscription} should
+ * currently be showing (same lookups {@code HomeController} and
+ * {@code ForecastController} do for a full page load) and pushes it to
+ * that user's private STOMP queue. Weather fetches for the individual
+ * subscriptions are composed as a single {@link Flux} with bounded
+ * concurrency, so one broadcast tick can't fan out an unbounded burst of
+ * calls into {@link WeatherApiService} (which mostly resolves from the
+ * Redis-backed cache anyway, but a cache stampede past the TTL boundary is
+ * still worth capping). Each subscription's fetch/push is isolated with
+ * {@code onErrorResume}, so one user's failing/slow location can't stall or
+ * cancel the whole tick.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class WeatherLiveUpdateScheduler {
+
+ private final WeatherSubscriptionRegistry registry;
+ private final LocationService locationService;
+ private final WeatherApiService weatherApiService;
+ private final UnitConverter unitConverter;
+ private final SimpMessagingTemplate messagingTemplate;
+ private final ExecutorService weatherFetchExecutor;
+
+ @Value("${location.dashboard.page-size:12}")
+ private int dashboardPageSize;
+
+ @Value("${weather.live.max-concurrent-fetches:16}")
+ private int maxConcurrentFetches;
+
+ /**
+ * Runs one broadcast tick. {@code fixedDelayString} (not fixed-rate) so
+ * that if a tick ever runs long - a slow OpenWeatherMap response, a
+ * burst of subscribers - the next tick is scheduled relative to when
+ * this one actually finished, instead of piling up overlapping ticks.
+ */
+ @Scheduled(fixedDelayString = "${weather.live.push-interval-ms:60000}")
+ public void broadcast() {
+ Scheduler fetchScheduler = Schedulers.fromExecutor(weatherFetchExecutor);
+
+ Flux dashboardTicks = Flux.fromIterable(registry.dashboardSubscriptions())
+ .flatMap(subscription -> pushDashboardUpdate(subscription, fetchScheduler), maxConcurrentFetches);
+
+ Flux forecastTicks = Flux.fromIterable(registry.forecastSubscriptions())
+ .flatMap(subscription -> pushForecastUpdate(subscription, fetchScheduler), maxConcurrentFetches);
+
+ Flux.merge(dashboardTicks, forecastTicks).then().block();
+ }
+
+ private Mono pushDashboardUpdate(DashboardSubscription subscription, Scheduler fetchScheduler) {
+ return Mono.fromRunnable(() -> doPushDashboardUpdate(subscription))
+ .subscribeOn(fetchScheduler)
+ .then()
+ .onErrorResume(ex -> {
+ log.warn("Live dashboard push failed for user={}: {}", subscription.username(), ex.getMessage());
+ return Mono.empty();
+ });
+ }
+
+ private void doPushDashboardUpdate(DashboardSubscription subscription) {
+ Page locationPage = locationService.getByUserIdSorted(
+ subscription.userId(), subscription.sort(), PageRequest.of(subscription.page(), dashboardPageSize));
+
+ List updates = new ArrayList<>();
+ List unavailable = new ArrayList<>();
+
+ for (LocationDto location : locationPage.getContent()) {
+ try {
+ WeatherDto weather = unitConverter.toDisplayUnits(
+ weatherApiService.getWeatherByLocation(location), subscription.units());
+ updates.add(new DashboardLocationWeather()
+ .setLocationId(location.getId())
+ .setLocationName(location.getName())
+ .setWeather(weather));
+ } catch (RuntimeException ex) {
+ log.debug("Live weather fetch failed for location '{}' (user={}): {}",
+ location.getName(), subscription.username(), ex.getMessage());
+ unavailable.add(location.getName());
+ }
+ }
+
+ if (updates.isEmpty() && unavailable.isEmpty()) {
+ return;
+ }
+
+ messagingTemplate.convertAndSendToUser(subscription.username(), "/queue/dashboard",
+ new DashboardUpdateMessage().setLocations(updates).setUnavailableLocationNames(unavailable));
+ }
+
+ private Mono pushForecastUpdate(ForecastSubscription subscription, Scheduler fetchScheduler) {
+ return Mono.fromRunnable(() -> doPushForecastUpdate(subscription))
+ .subscribeOn(fetchScheduler)
+ .then()
+ .onErrorResume(ex -> {
+ log.warn("Live forecast push failed for user={}: {}", subscription.username(), ex.getMessage());
+ return Mono.empty();
+ });
+ }
+
+ private void doPushForecastUpdate(ForecastSubscription subscription) {
+ try {
+ locationService.getByCoordinatesAndUserId(subscription.latitude(), subscription.longitude(), subscription.userId());
+ } catch (LocationNotFoundException ex) {
+ log.debug("Live forecast subscription for user={} no longer owns lat={}, lon={}; skipping tick",
+ subscription.username(), subscription.latitude(), subscription.longitude());
+ return;
+ }
+
+ List hourly = unitConverter.toDisplayUnits(
+ weatherApiService.getHourlyForecastByCoordinates(subscription.latitude(), subscription.longitude()), subscription.units());
+ List daily = unitConverter.toDisplayUnits(
+ weatherApiService.getDailyForecastByCoordinates(subscription.latitude(), subscription.longitude()), subscription.units());
+
+ messagingTemplate.convertAndSendToUser(subscription.username(), "/queue/forecast",
+ new ForecastUpdateMessage().setHourlyForecast(hourly).setDailyForecast(daily));
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java b/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java
new file mode 100644
index 0000000..a0aa8f7
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/WeatherSocketController.java
@@ -0,0 +1,73 @@
+package com.weatherviewer.websocket;
+
+import com.weatherviewer.dto.ws.DashboardSubscribeRequest;
+import com.weatherviewer.dto.ws.ForecastSubscribeRequest;
+import com.weatherviewer.security.SecUser;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.messaging.handler.annotation.MessageMapping;
+import org.springframework.messaging.handler.annotation.Payload;
+import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Controller;
+
+import java.security.Principal;
+import java.util.Set;
+
+/**
+ * STOMP message handlers for {@code /app/**} destinations. These don't
+ * return a value (no {@code @SendTo}) - registering a subscription here
+ * just updates {@link WeatherSubscriptionRegistry}; the actual weather
+ * pushes are sent later, out-of-band, by {@link WeatherLiveUpdateScheduler}.
+ */
+@Controller
+@RequiredArgsConstructor
+@Slf4j
+public class WeatherSocketController {
+
+ private static final Set VALID_SORTS = Set.of("date", "nameAsc", "nameDesc", "favoriteFirst", "favoritesOnly");
+
+ private final WeatherSubscriptionRegistry registry;
+
+ /** Registers (or replaces) this session's live dashboard subscription for the given sort/page. */
+ @MessageMapping("/dashboard.subscribe")
+ public void subscribeDashboard(@Payload DashboardSubscribeRequest request, Principal principal,
+ SimpMessageHeaderAccessor headerAccessor) {
+ SecUser user = extractUser(principal);
+ String sessionId = headerAccessor.getSessionId();
+ if (user == null || sessionId == null) {
+ log.warn("Ignoring dashboard.subscribe with no authenticated principal or session id");
+ return;
+ }
+
+ String sort = request.getSort() != null && VALID_SORTS.contains(request.getSort()) ? request.getSort() : "date";
+ int page = request.getPage() != null && request.getPage() > 0 ? request.getPage() : 0;
+
+ registry.registerDashboard(new DashboardSubscription(sessionId, user.getId(), user.getUsername(), user.getUnits(), sort, page));
+ log.debug("Live dashboard subscription registered: user={}, sort={}, page={}", user.getUsername(), sort, page);
+ }
+
+ /** Registers (or replaces) this session's live forecast subscription for the given coordinates. */
+ @MessageMapping("/forecast.subscribe")
+ public void subscribeForecast(@Payload ForecastSubscribeRequest request, Principal principal,
+ SimpMessageHeaderAccessor headerAccessor) {
+ SecUser user = extractUser(principal);
+ String sessionId = headerAccessor.getSessionId();
+ if (user == null || sessionId == null || request.getLat() == null || request.getLon() == null) {
+ log.warn("Ignoring forecast.subscribe with missing principal, session id, or coordinates");
+ return;
+ }
+
+ registry.registerForecast(new ForecastSubscription(sessionId, user.getId(), user.getUsername(), user.getUnits(),
+ request.getLat(), request.getLon()));
+ log.debug("Live forecast subscription registered: user={}, lat={}, lon={}", user.getUsername(), request.getLat(), request.getLon());
+ }
+
+ private SecUser extractUser(Principal principal) {
+ if (principal instanceof Authentication authentication && authentication.getPrincipal() instanceof SecUser secUser) {
+ return secUser;
+ }
+ return null;
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java b/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java
new file mode 100644
index 0000000..f9dc958
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/WeatherSocketEventListener.java
@@ -0,0 +1,28 @@
+package com.weatherviewer.websocket;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.messaging.SessionDisconnectEvent;
+
+/**
+ * Removes a session's live-update subscription (dashboard or forecast) as
+ * soon as its WebSocket connection closes - tab closed, page navigated
+ * away, network drop, etc. - so {@link WeatherLiveUpdateScheduler} never
+ * wastes a fetch/push on a client that's no longer listening.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class WeatherSocketEventListener {
+
+ private final WeatherSubscriptionRegistry registry;
+
+ @EventListener
+ public void onSessionDisconnect(SessionDisconnectEvent event) {
+ registry.remove(event.getSessionId());
+ log.debug("WebSocket session {} disconnected; live-update subscription removed", event.getSessionId());
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java b/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java
new file mode 100644
index 0000000..b85c321
--- /dev/null
+++ b/src/main/java/com/weatherviewer/websocket/WeatherSubscriptionRegistry.java
@@ -0,0 +1,57 @@
+package com.weatherviewer.websocket;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Tracks which connected WebSocket sessions want live weather pushes, and
+ * for what (a paginated/sorted dashboard view, or a single forecast-page
+ * location).
+ *
+ * Keyed by STOMP session ID rather than user ID: a user could have the
+ * dashboard open in one tab and a forecast page in another, each getting
+ * its own independent live feed. A session holds at most one subscription
+ * of each kind at a time - registering a new one for a session replaces
+ * whatever that session was previously subscribed to.
+ *