From 21b6de0f1559f1fe5a1eb2f3d226cb00b6dbf59b Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 5 Aug 2024 08:35:16 +0100 Subject: [PATCH] api: impl pagination for top scores endpoint and add user + leaderboard caching --- API/pom.xml | 5 + .../fascinated/common/PaginationBuilder.java | 108 ++++++++++++++++++ .../controller/ScoresController.java | 10 +- .../java/cc/fascinated/model/score/Score.java | 1 + .../repository/ScoreRepository.java | 2 +- .../services/ScoreSaberService.java | 19 ++- .../cc/fascinated/services/ScoreService.java | 74 ++++++------ .../cc/fascinated/services/UserService.java | 21 +++- 8 files changed, 196 insertions(+), 44 deletions(-) create mode 100644 API/src/main/java/cc/fascinated/common/PaginationBuilder.java diff --git a/API/pom.xml b/API/pom.xml index d6b929e..38c4c92 100644 --- a/API/pom.xml +++ b/API/pom.xml @@ -80,6 +80,11 @@ com.konghq unirest-modules-jackson + + net.jodah + expiringmap + 0.5.11 + diff --git a/API/src/main/java/cc/fascinated/common/PaginationBuilder.java b/API/src/main/java/cc/fascinated/common/PaginationBuilder.java new file mode 100644 index 0000000..9d5625b --- /dev/null +++ b/API/src/main/java/cc/fascinated/common/PaginationBuilder.java @@ -0,0 +1,108 @@ +package cc.fascinated.common; + +import cc.fascinated.exception.impl.BadRequestException; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.function.Supplier; + +/** + * @author Fascinated (fascinated7) + */ +@Getter +public class PaginationBuilder { + /** + * The number of items per page. + */ + private int itemsPerPage; + + /** + * The items to paginate. + */ + private List items; + + /** + * Sets the number of items per page. + * + * @param itemsPerPage The number of items per page. + * @return The pagination builder. + */ + public PaginationBuilder itemsPerPage(int itemsPerPage) { + this.itemsPerPage = itemsPerPage; + return this; + } + + /** + * Sets the items to paginate. + * + * @param getItems The items to paginate. + * @return The pagination builder. + */ + public PaginationBuilder fillItems(Supplier> getItems) { + this.items = getItems.get(); + return this; + } + + /** + * Builds the pagination. + * + * @return The pagination. + */ + public PaginationBuilder build() { + return new PaginationBuilder<>(); + } + + /** + * Gets a page of items. + * + * @param page The page number. + * @return The page. + */ + public Page getPage(int page) { + int totalItems = this.items.size(); + int totalPages = (int) Math.ceil((double) totalItems / this.itemsPerPage); + + if (page < 1 || page > totalPages) { + throw new BadRequestException("Invalid page number"); + } + List items = this.items.subList((page - 1) * this.itemsPerPage, Math.min(page * this.itemsPerPage, totalItems)); + return new Page<>( + items, + new Page.Metadata(page, totalPages, totalItems) + ); + } + + @AllArgsConstructor + @Getter + public static class Page { + /** + * The items on the page. + */ + private final List items; + + /** + * The metadata of the page. + */ + private final Metadata metadata; + + @AllArgsConstructor + @Getter + public static class Metadata { + /** + * The page number. + */ + private final int page; + + /** + * The total number of pages. + */ + private final int totalPages; + + /** + * The total number of items. + */ + private final int totalItems; + } + } +} diff --git a/API/src/main/java/cc/fascinated/controller/ScoresController.java b/API/src/main/java/cc/fascinated/controller/ScoresController.java index cf3697a..9f4b5cf 100644 --- a/API/src/main/java/cc/fascinated/controller/ScoresController.java +++ b/API/src/main/java/cc/fascinated/controller/ScoresController.java @@ -9,8 +9,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; - /** * @author Fascinated (fascinated7) */ @@ -45,8 +43,12 @@ public class ScoresController { */ @ResponseBody @GetMapping(value = "/top/{platform}") - public ResponseEntity> getTopScores(@PathVariable String platform, @RequestParam(defaultValue = "false") boolean scoresonly) { - return ResponseEntity.ok(scoreService.getTopRankedScores(Platform.Platforms.getPlatform(platform), 50, scoresonly)); + public ResponseEntity getTopScores( + @PathVariable String platform, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "false") boolean scoresonly + ) { + return ResponseEntity.ok(scoreService.getTopRankedScores(Platform.Platforms.getPlatform(platform), page, scoresonly)); } /** diff --git a/API/src/main/java/cc/fascinated/model/score/Score.java b/API/src/main/java/cc/fascinated/model/score/Score.java index ca83c42..1ec01fb 100644 --- a/API/src/main/java/cc/fascinated/model/score/Score.java +++ b/API/src/main/java/cc/fascinated/model/score/Score.java @@ -78,6 +78,7 @@ public class Score { * e.g. 500pp *

*/ + @Indexed private Double pp; /** diff --git a/API/src/main/java/cc/fascinated/repository/ScoreRepository.java b/API/src/main/java/cc/fascinated/repository/ScoreRepository.java index 1fa6d24..2f3bad8 100644 --- a/API/src/main/java/cc/fascinated/repository/ScoreRepository.java +++ b/API/src/main/java/cc/fascinated/repository/ScoreRepository.java @@ -24,7 +24,7 @@ public interface ScoreRepository extends MongoRepository { @Aggregation(pipeline = { "{ $match: { platform: ?0, pp: { $gt: 0 } } }", "{ $sort: { pp: -1 } }", - "{ $limit: ?1 }" + "{ $limit: ?1 }", }) List getTopRankedScores(@NonNull Platform.Platforms platform, int amount); diff --git a/API/src/main/java/cc/fascinated/services/ScoreSaberService.java b/API/src/main/java/cc/fascinated/services/ScoreSaberService.java index 91dad97..04635fb 100644 --- a/API/src/main/java/cc/fascinated/services/ScoreSaberService.java +++ b/API/src/main/java/cc/fascinated/services/ScoreSaberService.java @@ -10,12 +10,16 @@ import cc.fascinated.repository.ScoreSaberLeaderboardRepository; import kong.unirest.core.HttpResponse; import lombok.NonNull; import lombok.extern.log4j.Log4j2; +import net.jodah.expiringmap.ExpirationPolicy; +import net.jodah.expiringmap.ExpiringMap; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.TimeUnit; /** * @author Fascinated (fascinated7) @@ -28,6 +32,12 @@ public class ScoreSaberService { private static final String GET_LEADERBOARD_ENDPOINT = SCORESABER_API + "leaderboard/by-id/%s/info"; private static final String GET_RANKED_LEADERBOARDS_ENDPOINT = SCORESABER_API + "leaderboards?ranked=true&page=%s&withMetadata=true"; + private final Map leaderboardCache = ExpiringMap.builder() + .maxSize(5_000) + .expirationPolicy(ExpirationPolicy.CREATED) + .expiration(30, TimeUnit.MINUTES) + .build(); + /** * The ScoreSaber leaderboard repository to use. */ @@ -69,9 +79,15 @@ public class ScoreSaberService { * @throws BadRequestException if an error occurred while getting the leaderboard */ public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId, boolean bypassCache) { + if (leaderboardCache.containsKey(leaderboardId) && !bypassCache) { // The leaderboard is cached + return leaderboardCache.get(leaderboardId); + } + Optional leaderboardOptional = leaderboardRepository.findById(leaderboardId); if (leaderboardOptional.isPresent() && !bypassCache) { // The leaderboard is cached - return leaderboardOptional.get(); + ScoreSaberLeaderboardToken leaderboard = leaderboardOptional.get(); + leaderboardCache.put(leaderboardId, leaderboard); + return leaderboard; } HttpResponse response = Request.get(GET_LEADERBOARD_ENDPOINT.formatted(leaderboardId), ScoreSaberLeaderboardToken.class); @@ -83,6 +99,7 @@ public class ScoreSaberService { } ScoreSaberLeaderboardToken leaderboard = response.getBody(); leaderboardRepository.save(leaderboard); + leaderboardCache.put(leaderboardId, leaderboard); return leaderboard; } diff --git a/API/src/main/java/cc/fascinated/services/ScoreService.java b/API/src/main/java/cc/fascinated/services/ScoreService.java index 60ffa27..893b31f 100644 --- a/API/src/main/java/cc/fascinated/services/ScoreService.java +++ b/API/src/main/java/cc/fascinated/services/ScoreService.java @@ -3,6 +3,7 @@ package cc.fascinated.services; import cc.fascinated.common.DateUtils; import cc.fascinated.common.EnumUtils; import cc.fascinated.common.MathUtils; +import cc.fascinated.common.PaginationBuilder; import cc.fascinated.model.leaderboard.Leaderboard; import cc.fascinated.model.score.DeviceInformation; import cc.fascinated.model.score.Score; @@ -74,42 +75,45 @@ public class ScoreService { * Gets the top ranked scores from the platform. * * @param platform The platform to get the scores from. - * @param amount The amount of scores to get. * @param scoresOnly Whether to only get the scores. * @return The scores. */ - public List getTopRankedScores(@NonNull Platform.Platforms platform, int amount, boolean scoresOnly) { - List trackedScores = scoreRepository.getTopRankedScores(platform, amount); - List scores = new ArrayList<>(); - for (Score score : trackedScores) { - ScoreSaberScore scoreSaberScore = (ScoreSaberScore) score; - UserDTO user = scoresOnly ? null : userService.getUser(score.getPlayerId()).getAsDTO(); - Leaderboard leaderboard = scoresOnly ? null : Leaderboard.getFromScoreSaberToken(scoreSaberService.getLeaderboard(score.getLeaderboardId())); + public PaginationBuilder.Page getTopRankedScores(@NonNull Platform.Platforms platform, int pageNumber, boolean scoresOnly) { + PaginationBuilder builder = new PaginationBuilder().build(); + builder.itemsPerPage(50); + builder.fillItems(() -> { + List foundScores = this.scoreRepository.getTopRankedScores(platform, 250); + List scores = new ArrayList<>(); + for (Score score : foundScores) { + ScoreSaberScore scoreSaberScore = (ScoreSaberScore) score; + UserDTO user = scoresOnly ? null : userService.getUser(score.getPlayerId()).getAsDTO(); + Leaderboard leaderboard = scoresOnly ? null : Leaderboard.getFromScoreSaberToken(scoreSaberService.getLeaderboard(score.getLeaderboardId())); - scores.add(new ScoreSaberScoreResponse( - score.getId(), - score.getPlayerId(), - score.getPlatform(), - score.getPlatformScoreId(), - score.getLeaderboardId(), - score.getRank(), - score.getAccuracy(), - score.getPp(), - score.getScore(), - score.getModifiers(), - score.getMisses(), - score.getBadCuts(), - score.getDeviceInformation(), - score.getTimestamp(), - scoreSaberScore.getWeight(), - scoreSaberScore.getMultiplier(), - scoreSaberScore.getMaxCombo(), - user, - leaderboard - )); - } - - return scores; + scores.add(new ScoreSaberScoreResponse( + score.getId(), + score.getPlayerId(), + score.getPlatform(), + score.getPlatformScoreId(), + score.getLeaderboardId(), + score.getRank(), + score.getAccuracy(), + score.getPp(), + score.getScore(), + score.getModifiers(), + score.getMisses(), + score.getBadCuts(), + score.getDeviceInformation(), + score.getTimestamp(), + scoreSaberScore.getWeight(), + scoreSaberScore.getMultiplier(), + scoreSaberScore.getMaxCombo(), + user, + leaderboard + )); + } + return scores; + }); + return builder.getPage(pageNumber); } /** @@ -245,7 +249,8 @@ public class ScoreService { * @param leaderboardId The leaderboard id to get the score from. * @return The previous score. */ - public @NonNull List getScoreHistory(@NonNull Platform.Platforms platform, @NonNull User user, @NonNull String leaderboardId) { + public @NonNull List getScoreHistory(@NonNull Platform.Platforms platform, @NonNull User + user, @NonNull String leaderboardId) { List foundScores = new ArrayList<>(this.scoreRepository.findScores(platform, user.getSteamId(), leaderboardId)); // Sort previous scores by timestamp (newest -> oldest) @@ -280,7 +285,8 @@ public class ScoreService { * @param score The score to log. * @param user The user who set the score. */ - private void logScore(@NonNull Platform.Platforms platform, @NonNull Leaderboard leaderboard, @NonNull Score score, + private void logScore(@NonNull Platform.Platforms platform, @NonNull Leaderboard leaderboard, @NonNull Score + score, @NonNull User user) { String platformName = EnumUtils.getEnumName(platform); boolean isRanked = score.getPp() != 0; diff --git a/API/src/main/java/cc/fascinated/services/UserService.java b/API/src/main/java/cc/fascinated/services/UserService.java index b86e98e..b32fa73 100644 --- a/API/src/main/java/cc/fascinated/services/UserService.java +++ b/API/src/main/java/cc/fascinated/services/UserService.java @@ -9,13 +9,12 @@ import cc.fascinated.model.user.history.HistoryPoint; import cc.fascinated.repository.UserRepository; import lombok.NonNull; import lombok.extern.log4j.Log4j2; +import net.jodah.expiringmap.ExpirationPolicy; +import net.jodah.expiringmap.ExpiringMap; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import java.util.Date; -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import java.util.*; import java.util.concurrent.TimeUnit; /** @@ -41,6 +40,15 @@ public class UserService { @NonNull private final ScoreSaberService scoreSaberService; + /** + * The user cache to use + */ + private final Map userCache = ExpiringMap.builder() + .maxSize(5_000) + .expirationPolicy(ExpirationPolicy.ACCESSED) + .expiration(1, TimeUnit.HOURS) + .build(); + @Autowired public UserService(@NonNull UserRepository userRepository, @NonNull ScoreSaberService scoreSaberService) { this.userRepository = userRepository; @@ -58,6 +66,10 @@ public class UserService { if (!this.isValidSteamId(steamId)) { throw new BadRequestException("Invalid steam id"); } + if (this.userCache.containsKey(steamId)) { + return this.userCache.get(steamId); + } + Optional userOptional = this.userRepository.findBySteamId(steamId); User user; boolean shouldUpdate = false; @@ -94,6 +106,7 @@ public class UserService { if (shouldUpdate) { this.saveUser(user); // Save the user } + this.userCache.put(steamId, user); return user; }