Public Access
feat(library): Steam name and avatar for the header
This commit is contained in:
1 parent
00a7991380
commit
ba75c00301
8 files changed
+258
-2
No files matched your search
@@ -6,16 +6,18 @@ import org.springframework.cache.annotation.EnableCaching;
|
|||||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableCaching
|
// Outside the resilience4j aspects, so a cache hit takes no Steam rate limiter permit.
|
||||||
|
@EnableCaching(order = Ordered.HIGHEST_PRECEDENCE)
|
||||||
public class CacheConfig {
|
public class CacheConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats");
|
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary");
|
||||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||||
.expireAfterWrite(10, TimeUnit.MINUTES)
|
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||||
.maximumSize(1000));
|
.maximumSize(1000));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import core_service.modules.library.api.dto.BacklogEntryDto;
|
|||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
import core_service.modules.library.api.dto.PageDto;
|
import core_service.modules.library.api.dto.PageDto;
|
||||||
|
import core_service.modules.library.api.dto.SteamProfileDto;
|
||||||
import core_service.modules.library.api.dto.SyncStatusDto;
|
import core_service.modules.library.api.dto.SyncStatusDto;
|
||||||
import core_service.modules.library.api.dto.WishlistDto;
|
import core_service.modules.library.api.dto.WishlistDto;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
@@ -12,6 +13,7 @@ import core_service.modules.library.service.BacklogService;
|
|||||||
import core_service.modules.library.service.GameDetailService;
|
import core_service.modules.library.service.GameDetailService;
|
||||||
import core_service.modules.library.service.LibraryQueryService;
|
import core_service.modules.library.service.LibraryQueryService;
|
||||||
import core_service.modules.library.service.LibrarySyncService;
|
import core_service.modules.library.service.LibrarySyncService;
|
||||||
|
import core_service.modules.library.service.SteamProfileService;
|
||||||
import core_service.modules.library.service.SyncCooldownException;
|
import core_service.modules.library.service.SyncCooldownException;
|
||||||
import core_service.shared.security.UserPrincipal;
|
import core_service.shared.security.UserPrincipal;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -49,6 +51,7 @@ public class LibraryController {
|
|||||||
private final LibraryQueryService queryService;
|
private final LibraryQueryService queryService;
|
||||||
private final GameDetailService gameDetailService;
|
private final GameDetailService gameDetailService;
|
||||||
private final BacklogService backlogService;
|
private final BacklogService backlogService;
|
||||||
|
private final SteamProfileService steamProfileService;
|
||||||
|
|
||||||
@GetMapping("/games-count")
|
@GetMapping("/games-count")
|
||||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
@@ -166,4 +169,9 @@ public class LibraryController {
|
|||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.ok(new SyncStatusDto(null, null)));
|
.orElseGet(() -> ResponseEntity.ok(new SyncStatusDto(null, null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/steam-profile")
|
||||||
|
public ResponseEntity<SteamProfileDto> getSteamProfile(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
return ResponseEntity.ok(steamProfileService.getProfile(principal.steamId()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's Steam name and avatar; both are null without a Steam ID or when Steam does not answer.
|
||||||
|
*/
|
||||||
|
public record SteamProfileDto(
|
||||||
|
String personaName,
|
||||||
|
String avatarUrl
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.api.dto.SteamProfileDto;
|
||||||
|
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SteamProfileService {
|
||||||
|
|
||||||
|
private static final SteamProfileDto NONE = new SteamProfileDto(null, null);
|
||||||
|
|
||||||
|
private final SteamApiService steamApiService;
|
||||||
|
|
||||||
|
public SteamProfileDto getProfile(Long steamId) {
|
||||||
|
if (steamId == null) return NONE;
|
||||||
|
return steamApiService.getPlayerSummary(String.valueOf(steamId))
|
||||||
|
// The frontend puts the avatar into an img src, so only an https URL passes.
|
||||||
|
.map(player -> new SteamProfileDto(player.personaname(),
|
||||||
|
player.avatarfull() != null && player.avatarfull().startsWith("https://") ? player.avatarfull() : null))
|
||||||
|
.orElse(NONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,8 @@ public class SteamApiService {
|
|||||||
return Optional.of(new SteamProfileAndGamesResponse(playerOpt.get(), games));
|
return Optional.of(new SteamProfileAndGamesResponse(playerOpt.get(), games));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asked on every page load for the avatar in the header.
|
||||||
|
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
|
||||||
@RateLimiter(name = "steamApi")
|
@RateLimiter(name = "steamApi")
|
||||||
@Retry(name = "steamApi")
|
@Retry(name = "steamApi")
|
||||||
public Optional<SteamPlayerSummaryResponse.Player> getPlayerSummary(String steamId) {
|
public Optional<SteamPlayerSummaryResponse.Player> getPlayerSummary(String steamId) {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||||
|
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.nullValue;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dev profile authenticates every request as user 1 with Steam ID 76561198000000001.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class LibraryControllerSteamProfileTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private SteamApiService steamApiService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void steamProfile_SendsNameAndAvatar() throws Exception {
|
||||||
|
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player(
|
||||||
|
"76561198000000001", "Lobo", null, "https://avatars.steamstatic.com/abc_full.jpg")));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/steam-profile"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.personaName").value("Lobo"))
|
||||||
|
.andExpect(jsonPath("$.avatarUrl").value("https://avatars.steamstatic.com/abc_full.jpg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void steamProfile_SteamDoesNotAnswer_SendsEmptyFields() throws Exception {
|
||||||
|
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/steam-profile"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.personaName").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.avatarUrl").value(nullValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.api.dto.SteamProfileDto;
|
||||||
|
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||||
|
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class SteamProfileServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static final String AVATAR = "https://avatars.steamstatic.com/abc_full.jpg";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SteamApiService steamApiService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private SteamProfileService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProfile_TakesNameAndAvatarFromSteam() {
|
||||||
|
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(
|
||||||
|
new SteamPlayerSummaryResponse.Player("76561198000000001", "Lobo", "https://steamcommunity.com/id/lobo", AVATAR)));
|
||||||
|
|
||||||
|
assertEquals(new SteamProfileDto("Lobo", AVATAR), service.getProfile(76561198000000001L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProfile_AvatarThatIsNotHttps_IsLeftOut() {
|
||||||
|
when(steamApiService.getPlayerSummary(any())).thenReturn(Optional.of(
|
||||||
|
new SteamPlayerSummaryResponse.Player("1", "Lobo", null, "javascript:alert(1)")));
|
||||||
|
|
||||||
|
assertEquals(new SteamProfileDto("Lobo", null), service.getProfile(1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProfile_SteamDoesNotAnswer_IsEmpty() {
|
||||||
|
when(steamApiService.getPlayerSummary(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertEquals(new SteamProfileDto(null, null), service.getProfile(1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProfile_NoSteamId_AsksSteamNothing() {
|
||||||
|
assertEquals(new SteamProfileDto(null, null), service.getProfile(null));
|
||||||
|
|
||||||
|
verify(steamApiService, never()).getPlayerSummary(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package core_service.modules.steam_adapter.service;
|
||||||
|
|
||||||
|
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||||
|
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Answers;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caching and resilience4j proxies around the real SteamApiService, with only the HTTP client mocked.
|
||||||
|
*/
|
||||||
|
class SteamApiServiceCacheTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final String STEAM_ID = "76561198000000001";
|
||||||
|
|
||||||
|
@MockitoBean(name = "steamRestClient", answers = Answers.RETURNS_DEEP_STUBS)
|
||||||
|
private RestClient steamRestClient;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamApiService steamApiService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RateLimiterRegistry rateLimiterRegistry;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CacheManager cacheManager;
|
||||||
|
|
||||||
|
private final AtomicInteger steamCalls = new AtomicInteger();
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void restore() {
|
||||||
|
RateLimiter limiter = rateLimiterRegistry.rateLimiter("steamApi");
|
||||||
|
limiter.changeTimeoutDuration(Duration.ofSeconds(5));
|
||||||
|
cacheManager.getCache("steamPlayerSummary").clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void steamAnswers(SteamPlayerSummaryResponse response) {
|
||||||
|
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamPlayerSummaryResponse.class))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
steamCalls.incrementAndGet();
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SteamPlayerSummaryResponse player() {
|
||||||
|
return new SteamPlayerSummaryResponse(new SteamPlayerSummaryResponse.SteamResponse(List.of(
|
||||||
|
new SteamPlayerSummaryResponse.Player(STEAM_ID, "Lobo", null, "https://avatars.steamstatic.com/a_full.jpg"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPlayerSummary_AsksSteamOncePerSteamId() {
|
||||||
|
steamAnswers(player());
|
||||||
|
|
||||||
|
assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent();
|
||||||
|
assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent();
|
||||||
|
|
||||||
|
assertThat(steamCalls).hasValue(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPlayerSummary_NoPlayer_IsNotCached() {
|
||||||
|
steamAnswers(new SteamPlayerSummaryResponse(new SteamPlayerSummaryResponse.SteamResponse(List.of())));
|
||||||
|
|
||||||
|
assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isEmpty();
|
||||||
|
assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isEmpty();
|
||||||
|
|
||||||
|
assertThat(steamCalls).hasValue(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header and the Dashboard ask on every page load, also while a library sync uses all Steam permits.
|
||||||
|
@Test
|
||||||
|
void getPlayerSummary_CacheHit_TakesNoRateLimiterPermit() {
|
||||||
|
steamAnswers(player());
|
||||||
|
assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent();
|
||||||
|
assertThat(steamCalls).hasValue(1);
|
||||||
|
|
||||||
|
RateLimiter limiter = rateLimiterRegistry.rateLimiter("steamApi");
|
||||||
|
limiter.changeTimeoutDuration(Duration.ZERO);
|
||||||
|
while (limiter.acquirePermission()) {
|
||||||
|
// drain the current period
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent();
|
||||||
|
assertThat(steamCalls).hasValue(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user