diff --git a/pom.xml b/pom.xml index b8ef856..6ff77ab 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,22 @@ spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-redis + + + io.lettuce + lettuce-core + + + + + redis.clients + jedis + + org.projectlombok diff --git a/src/main/java/me/braydon/mc/RESTfulMC.java b/src/main/java/me/braydon/mc/RESTfulMC.java index 9e61dbf..5d9ea44 100644 --- a/src/main/java/me/braydon/mc/RESTfulMC.java +++ b/src/main/java/me/braydon/mc/RESTfulMC.java @@ -5,8 +5,13 @@ import com.google.gson.GsonBuilder; import lombok.NonNull; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; import java.io.File; import java.nio.file.Files; @@ -23,6 +28,30 @@ public class RESTfulMC { .serializeNulls() .create(); + /** + * The Redis server host. + */ + @Value("${spring.data.redis.host}") + private String redisHost; + + /** + * The Redis server port. + */ + @Value("${spring.data.redis.port}") + private int redisPort; + + /** + * The Redis database index. + */ + @Value("${spring.data.redis.database}") + private int redisDatabase; + + /** + * The optional Redis password. + */ + @Value("${spring.data.redis.auth}") + private String redisAuth; + @SneakyThrows public static void main(@NonNull String[] args) { // Handle loading of our configuration file @@ -39,4 +68,34 @@ public class RESTfulMC { // Start the app SpringApplication.run(RESTfulMC.class, args); } + + /** + * Build the config to use for Redis. + * + * @return the config + * @see RedisTemplate for config + */ + @Bean @NonNull + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(jedisConnectionFactory()); + return template; + } + + /** + * Build the connection factory to use + * when making connections to Redis. + * + * @return the built factory + * @see JedisConnectionFactory for factory + */ + @Bean @NonNull + public JedisConnectionFactory jedisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); + config.setDatabase(redisDatabase); + if (!redisAuth.trim().isEmpty()) { // Auth with our provided password + config.setPassword(redisAuth); + } + return new JedisConnectionFactory(config); + } } \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/controller/PlayerController.java b/src/main/java/me/braydon/mc/controller/PlayerController.java index 8a4ffb1..2ab7cf4 100644 --- a/src/main/java/me/braydon/mc/controller/PlayerController.java +++ b/src/main/java/me/braydon/mc/controller/PlayerController.java @@ -4,7 +4,7 @@ import lombok.NonNull; import lombok.extern.log4j.Log4j2; import me.braydon.mc.exception.impl.BadRequestException; import me.braydon.mc.exception.impl.ResourceNotFoundException; -import me.braydon.mc.model.Player; +import me.braydon.mc.model.cache.CachedPlayer; import me.braydon.mc.service.MojangService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -41,7 +41,7 @@ public final class PlayerController { */ @GetMapping("/{query}") @ResponseBody - public ResponseEntity getPlayer(@PathVariable @NonNull String query) throws BadRequestException, ResourceNotFoundException { + public ResponseEntity getPlayer(@PathVariable @NonNull String query) throws BadRequestException, ResourceNotFoundException { return ResponseEntity.ofNullable(mojangService.getPlayer(query)); } } \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/exception/ExceptionControllerAdvice.java b/src/main/java/me/braydon/mc/exception/ExceptionControllerAdvice.java index 38ebdac..21ddeca 100644 --- a/src/main/java/me/braydon/mc/exception/ExceptionControllerAdvice.java +++ b/src/main/java/me/braydon/mc/exception/ExceptionControllerAdvice.java @@ -1,7 +1,7 @@ package me.braydon.mc.exception; import lombok.NonNull; -import me.braydon.mc.model.ErrorResponse; +import me.braydon.mc.model.response.ErrorResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; diff --git a/src/main/java/me/braydon/mc/model/Player.java b/src/main/java/me/braydon/mc/model/Player.java index fe18568..5bc7425 100644 --- a/src/main/java/me/braydon/mc/model/Player.java +++ b/src/main/java/me/braydon/mc/model/Player.java @@ -1,6 +1,8 @@ package me.braydon.mc.model; import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; import java.util.UUID; @@ -9,12 +11,17 @@ import java.util.UUID; * * @author Braydon */ -@NoArgsConstructor @AllArgsConstructor @Setter @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString -public final class Player { +@NoArgsConstructor +@AllArgsConstructor +@Setter @Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString +@RedisHash(value = "player", timeToLive = 60L * 60L) // 1 hour (in seconds) +public class Player { /** * The unique id of this player. */ - @EqualsAndHashCode.Include @NonNull private UUID uniqueId; + @Id @EqualsAndHashCode.Include @NonNull private UUID uniqueId; /** * The username of this player. @@ -22,7 +29,7 @@ public final class Player { @NonNull private String username; /** - * The profile actions this player has. + * The profile actions this player has, null if none. */ - @NonNull private ProfileAction[] profileActions; + private ProfileAction[] profileActions; } \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/model/cache/CachedPlayer.java b/src/main/java/me/braydon/mc/model/cache/CachedPlayer.java new file mode 100644 index 0000000..20538e2 --- /dev/null +++ b/src/main/java/me/braydon/mc/model/cache/CachedPlayer.java @@ -0,0 +1,29 @@ +package me.braydon.mc.model.cache; + +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import me.braydon.mc.model.Player; +import me.braydon.mc.model.ProfileAction; + +import java.io.Serializable; +import java.util.UUID; + +/** + * A cacheable {@link Player}. + * + * @author Braydon + */ +@Setter @Getter +public final class CachedPlayer extends Player implements Serializable { + /** + * The unix timestamp of when this + * player was cached, -1 if not cached. + */ + private long cached; + + public CachedPlayer(@NonNull UUID uniqueId, @NonNull String username, ProfileAction[] profileActions, long cached) { + super(uniqueId, username, profileActions); + this.cached = cached; + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/model/ErrorResponse.java b/src/main/java/me/braydon/mc/model/response/ErrorResponse.java similarity index 95% rename from src/main/java/me/braydon/mc/model/ErrorResponse.java rename to src/main/java/me/braydon/mc/model/response/ErrorResponse.java index 57a2677..c986c1b 100644 --- a/src/main/java/me/braydon/mc/model/ErrorResponse.java +++ b/src/main/java/me/braydon/mc/model/response/ErrorResponse.java @@ -1,4 +1,4 @@ -package me.braydon.mc.model; +package me.braydon.mc.model.response; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.*; diff --git a/src/main/java/me/braydon/mc/repository/PlayerCacheRepository.java b/src/main/java/me/braydon/mc/repository/PlayerCacheRepository.java new file mode 100644 index 0000000..2062501 --- /dev/null +++ b/src/main/java/me/braydon/mc/repository/PlayerCacheRepository.java @@ -0,0 +1,14 @@ +package me.braydon.mc.repository; + +import me.braydon.mc.model.Player; +import me.braydon.mc.model.cache.CachedPlayer; +import org.springframework.data.repository.CrudRepository; + +import java.util.UUID; + +/** + * A cache repository for {@link Player}'s. + * + * @author Braydon + */ +public interface PlayerCacheRepository extends CrudRepository { } \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/service/MojangService.java b/src/main/java/me/braydon/mc/service/MojangService.java index 9d4bc7f..70d7b83 100644 --- a/src/main/java/me/braydon/mc/service/MojangService.java +++ b/src/main/java/me/braydon/mc/service/MojangService.java @@ -8,11 +8,16 @@ import me.braydon.mc.common.web.JsonWebRequest; import me.braydon.mc.exception.impl.BadRequestException; import me.braydon.mc.exception.impl.ResourceNotFoundException; import me.braydon.mc.model.Player; +import me.braydon.mc.model.ProfileAction; +import me.braydon.mc.model.cache.CachedPlayer; import me.braydon.mc.model.token.MojangProfileToken; import me.braydon.mc.model.token.MojangUsernameToUUIDToken; +import me.braydon.mc.repository.PlayerCacheRepository; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Service; +import java.util.Optional; import java.util.UUID; /** @@ -28,6 +33,16 @@ public final class MojangService { private static final String UUID_TO_PROFILE = SESSION_SERVER_ENDPOINT + "/session/minecraft/profile/%s"; private static final String USERNAME_TO_UUID = API_ENDPOINT + "/users/profiles/minecraft/%s"; + /** + * The cache repository for {@link Player}'s. + */ + @NonNull private final PlayerCacheRepository playerCacheRepository; + + @Autowired + public MojangService(@NonNull PlayerCacheRepository playerCacheRepository) { + this.playerCacheRepository = playerCacheRepository; + } + /** * Get a player by their username or UUID. * @@ -37,7 +52,7 @@ public final class MojangService { * @throws ResourceNotFoundException if the player is not found */ @NonNull - public Player getPlayer(@NonNull String query) throws BadRequestException, ResourceNotFoundException { + public CachedPlayer getPlayer(@NonNull String query) throws BadRequestException, ResourceNotFoundException { log.info("Requesting player with query: {}", query); UUID uuid; // The player UUID to lookup @@ -54,6 +69,13 @@ public final class MojangService { log.info("Found UUID for username {}: {}", query, uuid); } + // Check the cache for the player + Optional cached = playerCacheRepository.findById(uuid); + if (cached.isPresent()) { // Respond with the cache if present + log.info("Found player in cache: {}", uuid); + return cached.get(); + } + // Send a request to Mojang requesting // the player profile by their UUID try { @@ -61,9 +83,19 @@ public final class MojangService { MojangProfileToken token = JsonWebRequest.makeRequest( UUID_TO_PROFILE.formatted(uuid), HttpMethod.GET ).execute(MojangProfileToken.class); + ProfileAction[] profileActions = token.getProfileActions(); - // Return our player model representing the requested player - return new Player(uuid, token.getName(), token.getProfileActions()); + // Build our player model, cache it, and then return it + CachedPlayer player = new CachedPlayer( + uuid, token.getName(), + profileActions.length == 0 ? null : profileActions, + System.currentTimeMillis() + ); + playerCacheRepository.save(player); + log.info("Cached player: {}", uuid); + + player.setCached(-1L); // Set to -1 to indicate it's not cached in the response + return player; } catch (JsonWebException ex) { // No profile found, return null if (ex.getStatusCode() == 400) {