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;
}