Public Access
feat(library): Steam name and avatar for the header
This commit is contained in:
1 parent
12970be9b2
commit
1044e53978
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.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
// Outside the resilience4j aspects, so a cache hit takes no Steam rate limiter permit.
|
||||
@EnableCaching(order = Ordered.HIGHEST_PRECEDENCE)
|
||||
public class CacheConfig {
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats");
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary");
|
||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||
.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.LibrarySummaryDto;
|
||||
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.WishlistDto;
|
||||
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.LibraryQueryService;
|
||||
import core_service.modules.library.service.LibrarySyncService;
|
||||
import core_service.modules.library.service.SteamProfileService;
|
||||
import core_service.modules.library.service.SyncCooldownException;
|
||||
import core_service.shared.security.UserPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -49,6 +51,7 @@ public class LibraryController {
|
||||
private final LibraryQueryService queryService;
|
||||
private final GameDetailService gameDetailService;
|
||||
private final BacklogService backlogService;
|
||||
private final SteamProfileService steamProfileService;
|
||||
|
||||
@GetMapping("/games-count")
|
||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@@ -166,4 +169,9 @@ public class LibraryController {
|
||||
.map(ResponseEntity::ok)
|
||||
.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));
|
||||
}
|
||||
|
||||
// Asked on every page load for the avatar in the header.
|
||||
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
|
||||
@RateLimiter(name = "steamApi")
|
||||
@Retry(name = "steamApi")
|
||||
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 76561198005679788.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class LibraryControllerSteamProfileTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private SteamApiService steamApiService;
|
||||
|
||||
@Test
|
||||
void steamProfile_SendsNameAndAvatar() throws Exception {
|
||||
when(steamApiService.getPlayerSummary("76561198005679788")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player(
|
||||
"76561198005679788", "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("76561198005679788")).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("76561198005679788")).thenReturn(Optional.of(
|
||||
new SteamPlayerSummaryResponse.Player("76561198005679788", "Lobo", "https://steamcommunity.com/id/lobo", AVATAR)));
|
||||
|
||||
assertEquals(new SteamProfileDto("Lobo", AVATAR), service.getProfile(76561198005679788L));
|
||||
}
|
||||
|
||||
@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 = "76561198005679788";
|
||||
|
||||
@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