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
21 changes: 21 additions & 0 deletions src/main/java/com/weatherviewer/config/AppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.web.client.RestClient;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;

import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
* General-purpose application beans that don't belong to a more specific
Expand Down Expand Up @@ -51,4 +54,22 @@ public LocaleResolver localeResolver() {
return slr;
}

/**
* Dedicated, bounded thread pool used by
* {@link com.weatherviewer.controller.HomeController} to fetch weather
* for a user's saved locations concurrently.
* <p>
* Deliberately separate from the JVM-wide common {@code ForkJoinPool}
* (what a bare {@code parallelStream()} would use): that pool is shared
* with unrelated parallel streams elsewhere in the JVM and has no
* request-scoped bound, so a user with many saved locations could
* starve it for everyone. Sized via {@code weather.dashboard.fetch-pool-size}
* (default 20) and shut down automatically on context close.
*/
@Bean(destroyMethod = "shutdown")
public ExecutorService weatherFetchExecutor(
@Value("${weather.dashboard.fetch-pool-size:20}") int poolSize) {
return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("weather-fetch-"));
}

}
117 changes: 85 additions & 32 deletions src/main/java/com/weatherviewer/controller/HomeController.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;

Expand All @@ -34,79 +38,128 @@ public class HomeController {
private final WeatherApiService weatherApiService;
private final LocationService locationService;
private final UnitConverter unitConverter;
private final ExecutorService weatherFetchExecutor;

/** Per-location timeout for the dashboard's concurrent weather fetches. */
@Value("${weather.dashboard.fetch-timeout-ms:4000}")
private long weatherFetchTimeoutMs;

/** Locations shown per dashboard page. Not user-adjustable, to keep the weather fan-out per request bounded. */
@Value("${location.dashboard.page-size:12}")
private int dashboardPageSize;

/**
* Renders the dashboard. Locations are fetched pre-sorted by
* {@code sort} ({@code nameAsc}, {@code nameDesc}, {@code favoriteFirst},
* {@code favoritesOnly}, or the default {@code date}), then current
* weather is fetched for all of them in parallel to keep page load fast
* when a user has many saved locations.
* Renders one page of the dashboard. Locations are fetched pre-sorted
* and pre-paged by {@code sort} ({@code nameAsc}, {@code nameDesc},
* {@code favoriteFirst}, {@code favoritesOnly}, or the default
* {@code date}) and {@code page} (0-based; page size is fixed via
* {@code location.dashboard.page-size}), then current weather is
* fetched for just that page's locations concurrently on a bounded
* pool ({@link com.weatherviewer.config.AppConfig#weatherFetchExecutor})
* to keep page load fast without competing with the JVM's shared
* common {@code ForkJoinPool}.
* <p>
* Each fetch is capped at {@code weather.dashboard.fetch-timeout-ms}
* and failures are isolated per location: a single slow or failing
* provider call surfaces that one location as unavailable instead of
* failing the whole dashboard.
*/
@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal SecUser user,
@RequestParam(required = false, defaultValue = "date") String sort) {
log.info("Home page requested by user '{}', sort={}", user.getUsername(), sort);

List<LocationDto> userLocations = switch (sort.toLowerCase()) {
case "nameasc" -> locationService.getByUserIdSortedByNameAsc(user.getId());
case "namedesc" -> locationService.getByUserIdSortedByNameDesc(user.getId());
case "favoritefirst" -> locationService.getByUserIdSortedByFavorite(user.getId());
case "favoritesonly" -> locationService.getFavoritesByUserId(user.getId());
default -> locationService.getByUserIdSortedByDate(user.getId());
};

if (!userLocations.isEmpty()) {
Map<LocationDto, WeatherDto> locationWeatherMap = userLocations.parallelStream()
@RequestParam(required = false, defaultValue = "date") String sort,
@RequestParam(required = false, defaultValue = "0") int page) {
int safePage = Math.max(page, 0);
log.info("Home page requested by user '{}', sort={}, page={}", user.getUsername(), sort, safePage);

Page<LocationDto> locationPage = locationService.getByUserIdSorted(
user.getId(), sort, PageRequest.of(safePage, dashboardPageSize));
List<LocationDto> pageLocations = locationPage.getContent();

if (!pageLocations.isEmpty()) {
Map<LocationDto, CompletableFuture<WeatherDto>> pendingWeather = pageLocations.stream()
.collect(Collectors.toMap(
Function.identity(),
location -> unitConverter.toDisplayUnits(
weatherApiService.getWeatherByLocation(location), user.getUnits()),
location -> CompletableFuture
.supplyAsync(() -> weatherApiService.getWeatherByLocation(location),
weatherFetchExecutor)
.orTimeout(weatherFetchTimeoutMs, TimeUnit.MILLISECONDS),
(existing, replacement) -> existing,
LinkedHashMap::new
));

Map<LocationDto, WeatherDto> locationWeatherMap = new LinkedHashMap<>();
List<String> unavailableLocationNames = new ArrayList<>();

for (Map.Entry<LocationDto, CompletableFuture<WeatherDto>> entry : pendingWeather.entrySet()) {
LocationDto location = entry.getKey();
try {
WeatherDto weather = entry.getValue().join();
locationWeatherMap.put(location, unitConverter.toDisplayUnits(weather, user.getUnits()));
} catch (CompletionException ex) {
log.warn("Weather fetch failed for location '{}' (user '{}'): {}",
location.getName(), user.getUsername(), ex.getCause() != null
? ex.getCause().getMessage() : ex.getMessage());
unavailableLocationNames.add(location.getName());
}
}

model.addAttribute("locationWeatherMap", locationWeatherMap);
log.info("Weather data prepared for {} locations", locationWeatherMap.size());
if (!unavailableLocationNames.isEmpty()) {
model.addAttribute("errorMessages", unavailableLocationNames.stream()
.map(name -> "Weather for \"" + name + "\" is temporarily unavailable")
.toList());
}
log.info("Weather data prepared for {} of {} locations on page {}",
locationWeatherMap.size(), pageLocations.size(), safePage);
}

model.addAttribute("login", user.getFullName());
model.addAttribute("sort", sort);
model.addAttribute("currentPage", locationPage.getNumber());
model.addAttribute("totalPages", locationPage.getTotalPages());
model.addAttribute("totalLocations", locationPage.getTotalElements());
model.addAttribute("hasPreviousPage", locationPage.hasPrevious());
model.addAttribute("hasNextPage", locationPage.hasNext());
model.addAttribute("temperatureSymbol", unitConverter.temperatureSymbol(user.getUnits()));
model.addAttribute("windSpeedUnit", unitConverter.windSpeedUnit(user.getUnits()));
return "home";
}

/** Deletes a saved location (ownership-checked) and redirects back to the dashboard preserving the current sort. */
/** Deletes a saved location (ownership-checked) and redirects back to the dashboard preserving the current sort/page. */
@DeleteMapping("/locations/{id}")
public String deleteLocation(@PathVariable UUID id,
@AuthenticationPrincipal SecUser user,
RedirectAttributes redirectAttributes,
@RequestParam(required = false, defaultValue = "date") String sort) {
@RequestParam(required = false, defaultValue = "date") String sort,
@RequestParam(required = false, defaultValue = "0") int page) {
log.info("User '{}' is deleting location with id={}", user.getUsername(), id);
locationService.deleteByIdAndUserId(id, user.getId());
redirectAttributes.addFlashAttribute("successMessage", "Location deleted successfully");
return "redirect:/?sort=" + sort;
return "redirect:/?sort=" + sort + "&page=" + page;
}

@PostMapping("/locations/{id}/favorite")
public String addToFavorite(@PathVariable UUID id,
@AuthenticationPrincipal SecUser user,
RedirectAttributes redirectAttributes,
@RequestParam(required = false, defaultValue = "date") String sort) {
@RequestParam(required = false, defaultValue = "date") String sort,
@RequestParam(required = false, defaultValue = "0") int page) {
log.info("User {} is adding location {} to favorites", user.getUsername(), id);
locationService.addToFavorite(id, user.getId());
redirectAttributes.addFlashAttribute("successMessage", "Location added to favorites");
return "redirect:/?sort=" + sort;
return "redirect:/?sort=" + sort + "&page=" + page;
}

@DeleteMapping("/locations/{id}/favorite")
public String removeFromFavorite(@PathVariable UUID id,
@AuthenticationPrincipal SecUser user,
RedirectAttributes redirectAttributes,
@RequestParam(required = false, defaultValue = "date") String sort) {
@RequestParam(required = false, defaultValue = "date") String sort,
@RequestParam(required = false, defaultValue = "0") int page) {
log.info("User {} is removing location {} from favorites", user.getUsername(), id);
locationService.removeFromFavorite(id, user.getId());
redirectAttributes.addFlashAttribute("successMessage", "Location removed from favorites");
return "redirect:/?sort=" + sort;
return "redirect:/?sort=" + sort + "&page=" + page;
}

}
2 changes: 2 additions & 0 deletions src/main/java/com/weatherviewer/dto/AddLocationDto.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.weatherviewer.dto;

import com.weatherviewer.validation.annotation.Latitude;
import com.weatherviewer.validation.annotation.LocationLimit;
import com.weatherviewer.validation.annotation.Longitude;
import com.weatherviewer.validation.annotation.UniqueLocation;
import io.swagger.v3.oas.annotations.media.Schema;
Expand All @@ -19,6 +20,7 @@
@ToString
@Accessors(chain = true)
@UniqueLocation
@LocationLimit
@Schema(description = "Payload for creating a saved location")
public class AddLocationDto {

Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.Set;

/**
* Servlet filter that enforces per-client request rate limits, backed by
Expand All @@ -43,6 +44,9 @@ public class RateLimitingFilter extends OncePerRequestFilter {
private static final String RATE_LIMIT_REMAINING_HEADER = "X-RateLimit-Remaining";
private static final String API_PATH_PREFIX = "/api";

private static final Set<String> STATIC_PREFIXES = Set.of("/css/", "/js/", "/images/", "/static/");
private static final Set<String> STATIC_EXTENSIONS = Set.of(".svg", ".png", ".ico", ".css", ".js");

private final RedisFixedWindowRateLimiter rateLimiter;
private final RateLimitProperties properties;
private List<IpAddressMatcher> trustedProxyMatchers;
Expand All @@ -61,6 +65,19 @@ void initTrustedProxies() {
.toList();
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
if (path == null) {
return false;
}

boolean isStaticPrefix = STATIC_PREFIXES.stream().anyMatch(path::startsWith);
boolean isStaticExt = STATIC_EXTENSIONS.stream().anyMatch(path::endsWith);

return isStaticPrefix || isStaticExt;
}

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/weatherviewer/repository/LocationRepository.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.weatherviewer.repository;

import com.weatherviewer.model.Location;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

Expand All @@ -21,6 +23,20 @@ public interface LocationRepository extends JpaRepository<Location, UUID> {
/** Returns all locations saved by the given user, in no particular order. */
List<Location> findByUserId(UUID userId);

/**
* Returns one page of a user's locations, sorted per the given
* {@link org.springframework.data.domain.Pageable}'s
* {@link org.springframework.data.domain.Sort}. Backs the paginated
* home dashboard.
*/
Page<Location> findByUserId(UUID userId, Pageable pageable);

/** Returns one page of a user's favorited locations, sorted per the given Pageable's Sort. */
Page<Location> findByUserIdAndFavoriteTrue(UUID userId, Pageable pageable);

/** Counts how many locations the given user has saved, used to enforce the per-user cap. */
long countByUserId(UUID userId);

/** Returns a user's locations, most recently added first. */
List<Location> findByUserIdOrderByCreatedAtDesc(UUID userId);

Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/weatherviewer/service/LocationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@ public interface LocationService {
/** Returns all locations saved by the given user, unsorted. */
List<LocationDto> getByUserId(UUID userId);

/**
* Counts how many locations the given user currently has saved.
* Backs {@link com.weatherviewer.validation.annotation.LocationLimit},
* which rejects new locations once a user hits the configured cap
* ({@code location.max-per-user}) — without this, a single user could
* save an unbounded number of locations and force the dashboard to
* fan out an unbounded number of weather calls on every page load.
*/
long countByUserId(UUID userId);

/**
* Returns one page of a user's saved locations in the given dashboard
* sort order ({@code date}, {@code nameAsc}, {@code nameDesc},
* {@code favoriteFirst}, or {@code favoritesOnly} — unrecognized/blank
* values fall back to {@code date}). Backs the paginated home
* dashboard, so a user with many saved locations only loads (and
* fetches weather for) one page's worth per request instead of
* everything at once.
*/
Page<LocationDto> getByUserIdSorted(UUID userId, String sort, Pageable pageable);

/** Looks up a user's saved location by its exact coordinates. */
LocationDto getByCoordinatesAndUserId(Double latitude, Double longitude, UUID userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import com.weatherviewer.service.LocationService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -68,6 +70,31 @@ public List<LocationDto> getByUserId(UUID userId) {
return locationMapper.toDtoList(locations);
}

@Override
public long countByUserId(UUID userId) {
return locationRepository.countByUserId(userId);
}

@Override
public Page<LocationDto> getByUserIdSorted(UUID userId, String sort, Pageable pageable) {
String normalizedSort = sort == null ? "date" : sort.toLowerCase();

if ("favoritesonly".equals(normalizedSort)) {
Pageable request = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
Sort.by(Sort.Direction.DESC, "createdAt"));
return locationRepository.findByUserIdAndFavoriteTrue(userId, request).map(locationMapper::toDto);
}

Sort sortOrder = switch (normalizedSort) {
case "nameasc" -> Sort.by(Sort.Direction.ASC, "name");
case "namedesc" -> Sort.by(Sort.Direction.DESC, "name");
case "favoritefirst" -> Sort.by(Sort.Order.desc("favorite"), Sort.Order.desc("createdAt"));
default -> Sort.by(Sort.Direction.DESC, "createdAt");
};
Pageable request = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortOrder);
return locationRepository.findByUserId(userId, request).map(locationMapper::toDto);
}

@Override
public LocationDto getByCoordinatesAndUserId(Double latitude, Double longitude, UUID userId) {
Location location = locationRepository.findByLatitudeAndLongitudeAndUserId(latitude, longitude, userId);
Expand Down
Loading
Loading