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,42 +75,45 @@ 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();
List<ScoreSaberScoreResponse> scores = new ArrayList<>(); builder.itemsPerPage(50);
for (Score score : trackedScores) { builder.fillItems(() -> {
ScoreSaberScore scoreSaberScore = (ScoreSaberScore) score; List<Score> foundScores = this.scoreRepository.getTopRankedScores(platform, 250);
UserDTO user = scoresOnly ? null : userService.getUser(score.getPlayerId()).getAsDTO(); List<ScoreSaberScoreResponse> scores = new ArrayList<>();
Leaderboard leaderboard = scoresOnly ? null : Leaderboard.getFromScoreSaberToken(scoreSaberService.getLeaderboard(score.getLeaderboardId())); 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( scores.add(new ScoreSaberScoreResponse(
score.getId(), score.getId(),
score.getPlayerId(), score.getPlayerId(),
score.getPlatform(), score.getPlatform(),
score.getPlatformScoreId(), score.getPlatformScoreId(),
score.getLeaderboardId(), score.getLeaderboardId(),
score.getRank(), score.getRank(),
score.getAccuracy(), score.getAccuracy(),
score.getPp(), score.getPp(),
score.getScore(), score.getScore(),
score.getModifiers(), score.getModifiers(),
score.getMisses(), score.getMisses(),
score.getBadCuts(), score.getBadCuts(),
score.getDeviceInformation(), score.getDeviceInformation(),
score.getTimestamp(), score.getTimestamp(),
scoreSaberScore.getWeight(), scoreSaberScore.getWeight(),
scoreSaberScore.getMultiplier(), scoreSaberScore.getMultiplier(),
scoreSaberScore.getMaxCombo(), scoreSaberScore.getMaxCombo(),
user, user,
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;
} }