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
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 47s
This commit is contained in:
parent
64b6ef1a7f
commit
21b6de0f15
@ -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>
|
||||||
|
108
API/src/main/java/cc/fascinated/common/PaginationBuilder.java
Normal file
108
API/src/main/java/cc/fascinated/common/PaginationBuilder.java
Normal file
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user