Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
Expand Down
12 changes: 8 additions & 4 deletions src/main/java/com/weatherviewer/WeatherViewerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {

/**
Expand Down
79 changes: 79 additions & 0 deletions src/main/java/com/weatherviewer/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* Destination layout:
* <ul>
* <li>{@code /app/**} - client-to-server, handled by {@link WeatherSocketController}</li>
* <li>{@code /user/queue/dashboard} - server-to-client, one user's dashboard weather</li>
* <li>{@code /user/queue/forecast} - server-to-client, one user's forecast-page weather</li>
* </ul>
* 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<String, Object> attributes) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
return authentication;
}
}

}
32 changes: 32 additions & 0 deletions src/main/java/com/weatherviewer/controller/ForecastController.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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
Expand All @@ -51,15 +59,39 @@ 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);

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<String, String> 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 "{}";
}
}

}
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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;

}
26 changes: 26 additions & 0 deletions src/main/java/com/weatherviewer/dto/ws/DashboardUpdateMessage.java
Original file line number Diff line number Diff line change
@@ -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<DashboardLocationWeather> locations;
private List<String> unavailableLocationNames;

}
Original file line number Diff line number Diff line change
@@ -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;

}
21 changes: 21 additions & 0 deletions src/main/java/com/weatherviewer/dto/ws/ForecastUpdateMessage.java
Original file line number Diff line number Diff line change
@@ -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<WeatherDto> hourlyForecast;
private List<WeatherDto> dailyForecast;

}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Loading