api: impl pagination for top scores endpoint and add user + leaderboard caching
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 47s

This commit is contained in:
Lee 2024-08-05 08:35:16 +01:00
parent 64b6ef1a7f
commit 21b6de0f15
8 changed files with 196 additions and 44 deletions

@ -80,6 +80,11 @@
<groupId>com.konghq</groupId> <groupId>com.konghq</groupId>
<artifactId>unirest-modules-jackson</artifactId> <artifactId>unirest-modules-jackson</artifactId>
</dependency> </dependency>
<dependency>
<groupId>net.jodah</groupId>
<artifactId>expiringmap</artifactId>
<version>0.5.11</version>
</dependency>
<!-- Libraries --> <!-- Libraries -->
<dependency> <dependency>

@ -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<T> {
/**
* The number of items per page.
*/
private int itemsPerPage;
/**
* The items to paginate.
*/
private List<T> items;
/**
* Sets the number of items per page.
*
* @param itemsPerPage The number of items per page.
* @return The pagination builder.
*/
public PaginationBuilder<T> 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<T> fillItems(Supplier<List<T>> getItems) {
this.items = getItems.get();
return this;
}
/**
* Builds the pagination.
*
* @return The pagination.
*/
public PaginationBuilder<T> build() {
return new PaginationBuilder<>();
}
/**
* Gets a page of items.
*
* @param page The page number.
* @return The page.
*/
public Page<T> 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<T> 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<T> {
/**
* The items on the page.
*/
private final List<T> 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;
}
}
}

@ -9,8 +9,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
/** /**
* @author Fascinated (fascinated7) * @author Fascinated (fascinated7)
*/ */
@ -45,8 +43,12 @@ public class ScoresController {
*/ */
@ResponseBody @ResponseBody
@GetMapping(value = "/top/{platform}") @GetMapping(value = "/top/{platform}")
public ResponseEntity<List<?>> getTopScores(@PathVariable String platform, @RequestParam(defaultValue = "false") boolean scoresonly) { public ResponseEntity<?> getTopScores(
return ResponseEntity.ok(scoreService.getTopRankedScores(Platform.Platforms.getPlatform(platform), 50, scoresonly)); @PathVariable String platform,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "false") boolean scoresonly
) {
return ResponseEntity.ok(scoreService.getTopRankedScores(Platform.Platforms.getPlatform(platform), page, scoresonly));
} }
/** /**

@ -78,6 +78,7 @@ public class Score {
* e.g. 500pp * e.g. 500pp
* </p> * </p>
*/ */
@Indexed
private Double pp; private Double pp;
/** /**

@ -24,7 +24,7 @@ public interface ScoreRepository extends MongoRepository<Score, Long> {
@Aggregation(pipeline = { @Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }", "{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $sort: { pp: -1 } }", "{ $sort: { pp: -1 } }",
"{ $limit: ?1 }" "{ $limit: ?1 }",
}) })
List<Score> getTopRankedScores(@NonNull Platform.Platforms platform, int amount); List<Score> getTopRankedScores(@NonNull Platform.Platforms platform, int amount);

@ -10,12 +10,16 @@ import cc.fascinated.repository.ScoreSaberLeaderboardRepository;
import kong.unirest.core.HttpResponse; import kong.unirest.core.HttpResponse;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.log4j.Log4j2; 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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit;
/** /**
* @author Fascinated (fascinated7) * @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_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 static final String GET_RANKED_LEADERBOARDS_ENDPOINT = SCORESABER_API + "leaderboards?ranked=true&page=%s&withMetadata=true";
private final Map<String, ScoreSaberLeaderboardToken> leaderboardCache = ExpiringMap.builder()
.maxSize(5_000)
.expirationPolicy(ExpirationPolicy.CREATED)
.expiration(30, TimeUnit.MINUTES)
.build();
/** /**
* The ScoreSaber leaderboard repository to use. * The ScoreSaber leaderboard repository to use.
*/ */
@ -69,9 +79,15 @@ public class ScoreSaberService {
* @throws BadRequestException if an error occurred while getting the leaderboard * @throws BadRequestException if an error occurred while getting the leaderboard
*/ */
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId, boolean bypassCache) { public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId, boolean bypassCache) {
if (leaderboardCache.containsKey(leaderboardId) && !bypassCache) { // The leaderboard is cached
return leaderboardCache.get(leaderboardId);
}
Optional<ScoreSaberLeaderboardToken> leaderboardOptional = leaderboardRepository.findById(leaderboardId); Optional<ScoreSaberLeaderboardToken> leaderboardOptional = leaderboardRepository.findById(leaderboardId);
if (leaderboardOptional.isPresent() && !bypassCache) { // The leaderboard is cached if (leaderboardOptional.isPresent() && !bypassCache) { // The leaderboard is cached
return leaderboardOptional.get(); ScoreSaberLeaderboardToken leaderboard = leaderboardOptional.get();
leaderboardCache.put(leaderboardId, leaderboard);
return leaderboard;
} }
HttpResponse<ScoreSaberLeaderboardToken> response = Request.get(GET_LEADERBOARD_ENDPOINT.formatted(leaderboardId), ScoreSaberLeaderboardToken.class); HttpResponse<ScoreSaberLeaderboardToken> response = Request.get(GET_LEADERBOARD_ENDPOINT.formatted(leaderboardId), ScoreSaberLeaderboardToken.class);
@ -83,6 +99,7 @@ public class ScoreSaberService {
} }
ScoreSaberLeaderboardToken leaderboard = response.getBody(); ScoreSaberLeaderboardToken leaderboard = response.getBody();
leaderboardRepository.save(leaderboard); leaderboardRepository.save(leaderboard);
leaderboardCache.put(leaderboardId, leaderboard);
return leaderboard; return leaderboard;
} }

@ -3,6 +3,7 @@ package cc.fascinated.services;
import cc.fascinated.common.DateUtils; import cc.fascinated.common.DateUtils;
import cc.fascinated.common.EnumUtils; import cc.fascinated.common.EnumUtils;
import cc.fascinated.common.MathUtils; import cc.fascinated.common.MathUtils;
import cc.fascinated.common.PaginationBuilder;
import cc.fascinated.model.leaderboard.Leaderboard; import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.DeviceInformation; import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.score.Score; import cc.fascinated.model.score.Score;
@ -74,14 +75,16 @@ public class ScoreService {
* Gets the top ranked scores from the platform. * Gets the top ranked scores from the platform.
* *
* @param platform The platform to get the scores from. * @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. * @param scoresOnly Whether to only get the scores.
* @return The scores. * @return The scores.
*/ */
public List<ScoreSaberScoreResponse> getTopRankedScores(@NonNull Platform.Platforms platform, int amount, boolean scoresOnly) { public PaginationBuilder.Page<ScoreSaberScoreResponse> getTopRankedScores(@NonNull Platform.Platforms platform, int pageNumber, boolean scoresOnly) {
List<Score> trackedScores = scoreRepository.getTopRankedScores(platform, amount); PaginationBuilder<ScoreSaberScoreResponse> builder = new PaginationBuilder<ScoreSaberScoreResponse>().build();
builder.itemsPerPage(50);
builder.fillItems(() -> {
List<Score> foundScores = this.scoreRepository.getTopRankedScores(platform, 250);
List<ScoreSaberScoreResponse> scores = new ArrayList<>(); List<ScoreSaberScoreResponse> scores = new ArrayList<>();
for (Score score : trackedScores) { for (Score score : foundScores) {
ScoreSaberScore scoreSaberScore = (ScoreSaberScore) score; ScoreSaberScore scoreSaberScore = (ScoreSaberScore) score;
UserDTO user = scoresOnly ? null : userService.getUser(score.getPlayerId()).getAsDTO(); UserDTO user = scoresOnly ? null : userService.getUser(score.getPlayerId()).getAsDTO();
Leaderboard leaderboard = scoresOnly ? null : Leaderboard.getFromScoreSaberToken(scoreSaberService.getLeaderboard(score.getLeaderboardId())); Leaderboard leaderboard = scoresOnly ? null : Leaderboard.getFromScoreSaberToken(scoreSaberService.getLeaderboard(score.getLeaderboardId()));
@ -108,8 +111,9 @@ public class ScoreService {
leaderboard leaderboard
)); ));
} }
return scores; return scores;
});
return builder.getPage(pageNumber);
} }
/** /**
@ -245,7 +249,8 @@ public class ScoreService {
* @param leaderboardId The leaderboard id to get the score from. * @param leaderboardId The leaderboard id to get the score from.
* @return The previous score. * @return The previous score.
*/ */
public @NonNull List<Score> getScoreHistory(@NonNull Platform.Platforms platform, @NonNull User user, @NonNull String leaderboardId) { public @NonNull List<Score> getScoreHistory(@NonNull Platform.Platforms platform, @NonNull User
user, @NonNull String leaderboardId) {
List<Score> foundScores = new ArrayList<>(this.scoreRepository.findScores(platform, user.getSteamId(), leaderboardId)); List<Score> foundScores = new ArrayList<>(this.scoreRepository.findScores(platform, user.getSteamId(), leaderboardId));
// Sort previous scores by timestamp (newest -> oldest) // Sort previous scores by timestamp (newest -> oldest)
@ -280,7 +285,8 @@ public class ScoreService {
* @param score The score to log. * @param score The score to log.
* @param user The user who set the score. * @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) { @NonNull User user) {
String platformName = EnumUtils.getEnumName(platform); String platformName = EnumUtils.getEnumName(platform);
boolean isRanked = score.getPp() != 0; boolean isRanked = score.getPp() != 0;

@ -9,13 +9,12 @@ import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.repository.UserRepository; import cc.fascinated.repository.UserRepository;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.log4j.Log4j2; 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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Date; import java.util.*;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@ -41,6 +40,15 @@ public class UserService {
@NonNull @NonNull
private final ScoreSaberService scoreSaberService; private final ScoreSaberService scoreSaberService;
/**
* The user cache to use
*/
private final Map<String, User> userCache = ExpiringMap.builder()
.maxSize(5_000)
.expirationPolicy(ExpirationPolicy.ACCESSED)
.expiration(1, TimeUnit.HOURS)
.build();
@Autowired @Autowired
public UserService(@NonNull UserRepository userRepository, @NonNull ScoreSaberService scoreSaberService) { public UserService(@NonNull UserRepository userRepository, @NonNull ScoreSaberService scoreSaberService) {
this.userRepository = userRepository; this.userRepository = userRepository;
@ -58,6 +66,10 @@ public class UserService {
if (!this.isValidSteamId(steamId)) { if (!this.isValidSteamId(steamId)) {
throw new BadRequestException("Invalid steam id"); throw new BadRequestException("Invalid steam id");
} }
if (this.userCache.containsKey(steamId)) {
return this.userCache.get(steamId);
}
Optional<User> userOptional = this.userRepository.findBySteamId(steamId); Optional<User> userOptional = this.userRepository.findBySteamId(steamId);
User user; User user;
boolean shouldUpdate = false; boolean shouldUpdate = false;
@ -94,6 +106,7 @@ public class UserService {
if (shouldUpdate) { if (shouldUpdate) {
this.saveUser(user); // Save the user this.saveUser(user); // Save the user
} }
this.userCache.put(steamId, user);
return user; return user;
} }