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>
<artifactId>unirest-modules-jackson</artifactId>
</dependency>
<dependency>
<groupId>net.jodah</groupId>
<artifactId>expiringmap</artifactId>
<version>0.5.11</version>
</dependency>
<!-- Libraries -->
<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.web.bind.annotation.*;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@ -45,8 +43,12 @@ public class ScoresController {
*/
@ResponseBody
@GetMapping(value = "/top/{platform}")
public ResponseEntity<List<?>> 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));
}
/**

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

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

@ -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<String, ScoreSaberLeaderboardToken> 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<ScoreSaberLeaderboardToken> 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<ScoreSaberLeaderboardToken> 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;
}

@ -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<ScoreSaberScoreResponse> getTopRankedScores(@NonNull Platform.Platforms platform, int amount, boolean scoresOnly) {
List<Score> trackedScores = scoreRepository.getTopRankedScores(platform, amount);
List<ScoreSaberScoreResponse> 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<ScoreSaberScoreResponse> getTopRankedScores(@NonNull Platform.Platforms platform, int pageNumber, boolean scoresOnly) {
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<>();
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<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));
// 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;

@ -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<String, User> 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<User> 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;
}