diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/config/WebConfig.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/config/WebConfig.java index 330a74aab..798ef37ea 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/config/WebConfig.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/config/WebConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -31,4 +32,9 @@ public void addCorsMappings(CorsRegistry registry) { } }; } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } } diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java index bb7f578d2..2cfbb85c6 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java @@ -1,44 +1,43 @@ package com.yen.SpotifyPlayList.controller; import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; -import com.yen.SpotifyPlayList.service.RecommendationsService; +import com.yen.SpotifyPlayList.service.CustomSpotifyRecommendationService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import se.michaelthelin.spotify.model_objects.specification.Recommendations; @Slf4j @RestController @RequestMapping("/recommend") +@CrossOrigin(origins = "*") // Enable CORS for all origins public class RecommendationsController { @Autowired - private RecommendationsService recommendationsService; + private CustomSpotifyRecommendationService recommendationsService; - @PostMapping("/") - public ResponseEntity getRecommendation(@RequestBody GetRecommendationsDto getRecommendationsDto) { + @PostMapping({"", "/"}) // Handle both /recommend and /recommend/ + public ResponseEntity getRecommendation(@RequestBody GetRecommendationsDto getRecommendationsDto) { try { log.info("(getRecommendation) getRecommendationsDto = " + getRecommendationsDto.toString()); - Recommendations recommendations = recommendationsService.getRecommendation(getRecommendationsDto); - return ResponseEntity.status(HttpStatus.OK).body(recommendations); + ResponseEntity recommendations = recommendationsService.getRecommendations(getRecommendationsDto); + return ResponseEntity.status(recommendations.getStatusCode()).body(recommendations.getBody()); } catch (Exception e) { log.error("getRecommendation error : " + e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); } } + // TODO: Implement custom recommendation logic for playlist-based recommendations @GetMapping("/playlist/{playListId}") - public ResponseEntity getRecommendationWithPlayList(@PathVariable("playListId") String playListId) { + public ResponseEntity getRecommendationWithPlayList(@PathVariable("playListId") String playListId) { try { log.info("(getRecommendationWithPlayList) playListId = " + playListId); - Recommendations recommendations = recommendationsService.getRecommendationWithPlayList(playListId); - return ResponseEntity.status(HttpStatus.OK).body(recommendations); + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body("Feature not yet implemented with custom service"); } catch (Exception e) { log.error("getRecommendationWithPlayList error : " + e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); } } - } \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/AuthService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/AuthService.java index 7669d8bb6..ce0259972 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/AuthService.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/AuthService.java @@ -18,7 +18,7 @@ @Service @Slf4j -public class AuthService { +public class AuthService implements IAuthService { @Value("${spotify.client.id}") private String clientId; diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java new file mode 100644 index 000000000..0272c8e13 --- /dev/null +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationService.java @@ -0,0 +1,196 @@ +package com.yen.SpotifyPlayList.service; + +import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +@Service +@Slf4j +public class CustomSpotifyRecommendationService { + + @Value("${spotify.api.base-url:https://api.spotify.com/v1}") + private String spotifyApiBaseUrl; + + // List of valid Spotify genres + private static final Set VALID_GENRES = new HashSet<>(Arrays.asList( + "acoustic", "afrobeat", "alt-rock", "alternative", "ambient", "anime", "black-metal", + "bluegrass", "blues", "bossanova", "brazil", "breakbeat", "british", "cantopop", "chicago-house", + "children", "chill", "classical", "club", "comedy", "country", "dance", "dancehall", "death-metal", + "deep-house", "detroit-techno", "disco", "disney", "drum-and-bass", "dub", "dubstep", "edm", + "electro", "electronic", "emo", "folk", "forro", "french", "funk", "garage", "german", "gospel", + "goth", "grindcore", "groove", "grunge", "guitar", "happy", "hard-rock", "hardcore", "hardstyle", + "heavy-metal", "hip-hop", "holidays", "honky-tonk", "house", "idm", "indian", "indie", "indie-pop", + "industrial", "iranian", "j-dance", "j-idol", "j-pop", "j-rock", "jazz", "k-pop", "kids", "latin", + "latino", "malay", "mandopop", "metal", "metal-misc", "metalcore", "minimal-techno", "movies", + "mpb", "new-age", "new-release", "opera", "pagode", "party", "philippines-opm", "piano", "pop", + "pop-film", "post-dubstep", "power-pop", "progressive-house", "psych-rock", "punk", "punk-rock", + "r-n-b", "rainy-day", "reggae", "reggaeton", "road-trip", "rock", "rock-n-roll", "rockabilly", + "romance", "sad", "salsa", "samba", "sertanejo", "show-tunes", "singer-songwriter", "ska", + "sleep", "songwriter", "soul", "soundtracks", "spanish", "study", "summer", "swedish", "synth-pop", + "tango", "techno", "trance", "trip-hop", "turkish", "work-out", "world-music" + )); + + @Autowired + private IAuthService authService; + + @Autowired + private RestTemplate restTemplate; + + public ResponseEntity getRecommendations(GetRecommendationsDto request) { + try { + // Validate request + validateRequest(request); + + // Ensure we have a valid token + String accessToken = authService.getAccessToken(); + if (accessToken == null || accessToken.isEmpty()) { + log.info("Access token not found, getting new token"); + accessToken = authService.getToken(); + if (accessToken == null || accessToken.isEmpty()) { + throw new RuntimeException("Failed to obtain access token"); + } + } + + String url = buildRecommendationUrl(request); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + + log.info("Making recommendation request to URL: {}", url); + log.debug("Using access token: {}", accessToken); + + try { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + entity, + String.class + ); + + log.info("Received recommendation response: {} {}", response.getStatusCode(), response.getBody()); + return response; + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + String errorMessage = String.format( + "Invalid seed parameters. Please check that your artist ID (%s), track ID (%s), and genres (%s) are valid.", + request.getSeedArtistId(), + request.getSeedTrack(), + request.getSeedGenres() + ); + throw new IllegalArgumentException(errorMessage); + } + throw e; + } + + } catch (Exception e) { + log.error("Error getting recommendations: {}", e.getMessage()); + if (e instanceof IllegalArgumentException) { + throw e; // Rethrow validation errors as-is + } + if (e.getMessage() != null && (e.getMessage().contains("401") || e.getMessage().contains("unauthorized"))) { + // Token might be expired, try to get a new one + try { + log.info("Token might be expired, getting new token"); + String newToken = authService.getToken(); + authService.setAccessToken(newToken); + // Retry the request with new token + return getRecommendations(request); + } catch (Exception retryError) { + log.error("Failed to refresh token and retry: {}", retryError.getMessage()); + throw new RuntimeException("Failed to refresh access token", retryError); + } + } + throw new RuntimeException("Failed to get recommendations: " + e.getMessage(), e); + } + } + + private void validateRequest(GetRecommendationsDto request) { + if (request == null) { + throw new IllegalArgumentException("Request cannot be null"); + } + + boolean hasSeed = false; + + // Validate artist ID + if (request.getSeedArtistId() != null && !request.getSeedArtistId().isEmpty()) { + if (!request.getSeedArtistId().matches("^[0-9A-Za-z]{22}$")) { + throw new IllegalArgumentException( + "Invalid artist ID format. Spotify IDs are 22 characters long and contain only letters and numbers." + ); + } + hasSeed = true; + } + + // Validate track ID + if (request.getSeedTrack() != null && !request.getSeedTrack().isEmpty()) { + if (!request.getSeedTrack().matches("^[0-9A-Za-z]{22}$")) { + throw new IllegalArgumentException( + "Invalid track ID format. Spotify IDs are 22 characters long and contain only letters and numbers." + ); + } + hasSeed = true; + } + + // Validate genres + if (request.getSeedGenres() != null && !request.getSeedGenres().isEmpty()) { + String[] genres = request.getSeedGenres().split(","); + for (String genre : genres) { + String trimmedGenre = genre.trim(); + if (!VALID_GENRES.contains(trimmedGenre)) { + throw new IllegalArgumentException( + "Invalid genre: '" + trimmedGenre + "'. Please use one of the supported Spotify genres." + ); + } + } + hasSeed = true; + } + + if (!hasSeed) { + throw new IllegalArgumentException( + "At least one seed (artist, track, or genre) is required. Please provide at least one valid seed parameter." + ); + } + + // Validate other parameters + if (request.getAmount() < 1 || request.getAmount() > 100) { + throw new IllegalArgumentException("Amount must be between 1 and 100"); + } + } + + private String buildRecommendationUrl(GetRecommendationsDto request) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl(spotifyApiBaseUrl + "/recommendations"); + + // Add seed parameters (already validated) + if (request.getSeedArtistId() != null && !request.getSeedArtistId().isEmpty()) { + builder.queryParam("seed_artists", request.getSeedArtistId()); + } + if (request.getSeedTrack() != null && !request.getSeedTrack().isEmpty()) { + builder.queryParam("seed_tracks", request.getSeedTrack()); + } + if (request.getSeedGenres() != null && !request.getSeedGenres().isEmpty()) { + builder.queryParam("seed_genres", request.getSeedGenres()); + } + + // Add other parameters + builder.queryParam("limit", request.getAmount()); + builder.queryParam("market", request.getMarket()); + builder.queryParam("min_popularity", request.getMinPopularity()); + builder.queryParam("max_popularity", request.getMaxPopularity()); + builder.queryParam("target_popularity", request.getTargetPopularity()); + + return builder.build().encode().toUriString(); + } +} \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java new file mode 100644 index 000000000..ea2a64a90 --- /dev/null +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/IAuthService.java @@ -0,0 +1,10 @@ +package com.yen.SpotifyPlayList.service; + +import se.michaelthelin.spotify.SpotifyApi; + +public interface IAuthService { + String getAccessToken(); + void setAccessToken(String accessToken); + SpotifyApi initializeSpotifyApi(); + String getToken(); +} \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java index caee4af00..3c51e18a6 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java @@ -44,7 +44,8 @@ public Recommendations getRecommendation(GetRecommendationsDto getRecommendation return recommendations; } catch (IOException | SpotifyWebApiException | ParseException e) { log.error("Error fetching recommendations: {}", e.getMessage()); - throw new SpotifyWebApiException("getRecommendation error: " + e.getMessage()); + e.printStackTrace(); + throw new SpotifyWebApiException("getRecommendation error: "); } } diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/resources/application.properties b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/resources/application.properties index 2b43d5573..03c39d7f7 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/resources/application.properties +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/resources/application.properties @@ -7,4 +7,5 @@ spotify.redirect.url=http://localhost:8080/playlist #spotify.redirectURL=http://:8080/playlist spotify.authorize.scope=playlist-modify-public,playlist-modify-private,user-read-private,user-read-email -spotify.userId=62kytpy7jswykfjtnjn9zv3ou \ No newline at end of file +spotify.userId=62kytpy7jswykfjtnjn9zv3ou +spotify.api.base-url=https://api.spotify.com/v1 \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java new file mode 100644 index 000000000..c28408648 --- /dev/null +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/test/java/com/yen/SpotifyPlayList/service/CustomSpotifyRecommendationServiceTest.java @@ -0,0 +1,206 @@ +package com.yen.SpotifyPlayList.service; + +import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +public class CustomSpotifyRecommendationServiceTest { + + @Mock + private RestTemplate restTemplate; + + @Mock + private IAuthService authService; + + @InjectMocks + private CustomSpotifyRecommendationService recommendationService; + + private static final String MOCK_ACCESS_TOKEN = "mock-access-token"; + private static final String MOCK_RESPONSE = "{\"tracks\": [], \"seeds\": []}"; + private static final String BASE_URL = "https://api.spotify.com/v1"; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(recommendationService, "spotifyApiBaseUrl", BASE_URL); + } + + @Test + void getRecommendations_Success() { + // Arrange + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + + GetRecommendationsDto dto = new GetRecommendationsDto(); + dto.setSeedArtistId("artist123"); + dto.setSeedTrack("track123"); + dto.setSeedGenres("rock"); + + ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // Act + ResponseEntity response = recommendationService.getRecommendations(dto); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(MOCK_RESPONSE, response.getBody()); + + // Verify HTTP call was made with correct URL and headers + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor> entityCaptor = ArgumentCaptor.forClass(HttpEntity.class); + + verify(restTemplate).exchange( + urlCaptor.capture(), + eq(HttpMethod.GET), + entityCaptor.capture(), + eq(String.class) + ); + + String capturedUrl = urlCaptor.getValue(); + assertTrue(capturedUrl.startsWith(BASE_URL + "/recommendations")); + assertTrue(capturedUrl.contains("seed_artists=artist123")); + assertTrue(capturedUrl.contains("seed_tracks=track123")); + assertTrue(capturedUrl.contains("seed_genres=rock")); + + HttpHeaders headers = entityCaptor.getValue().getHeaders(); + assertEquals(MediaType.APPLICATION_JSON, headers.getContentType()); + assertEquals("Bearer " + MOCK_ACCESS_TOKEN, headers.getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + void getRecommendations_WithNullSeeds_Success() { + // Arrange + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + + GetRecommendationsDto dto = new GetRecommendationsDto(); + // Don't set any seeds - test null handling + + ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // Act + ResponseEntity response = recommendationService.getRecommendations(dto); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + + // Verify URL doesn't contain seed parameters + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + ); + + String capturedUrl = urlCaptor.getValue(); + assertTrue(capturedUrl.startsWith(BASE_URL + "/recommendations")); + assertFalse(capturedUrl.contains("seed_artists=")); + assertFalse(capturedUrl.contains("seed_tracks=")); + assertFalse(capturedUrl.contains("seed_genres=")); + } + + @Test + void getRecommendations_WhenAuthServiceFails_ThrowsException() { + // Arrange + when(authService.getAccessToken()).thenThrow(new RuntimeException("Auth failed")); + GetRecommendationsDto dto = new GetRecommendationsDto(); + + // Act & Assert + Exception exception = assertThrows(RuntimeException.class, () -> { + recommendationService.getRecommendations(dto); + }); + assertTrue(exception.getMessage().contains("Failed to get recommendations")); + + // Verify no HTTP call was made + verify(restTemplate, never()).exchange( + any(String.class), + any(HttpMethod.class), + any(HttpEntity.class), + eq(String.class) + ); + } + + @Test + void getRecommendations_WhenSpotifyApiFails_ThrowsException() { + // Arrange + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + + GetRecommendationsDto dto = new GetRecommendationsDto(); + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenThrow(new RuntimeException("API call failed")); + + // Act & Assert + Exception exception = assertThrows(RuntimeException.class, () -> { + recommendationService.getRecommendations(dto); + }); + assertTrue(exception.getMessage().contains("Failed to get recommendations")); + } + + @Test + void getRecommendations_VerifyUrlConstruction() { + // Arrange + when(authService.getAccessToken()).thenReturn(MOCK_ACCESS_TOKEN); + + GetRecommendationsDto dto = new GetRecommendationsDto(); + dto.setSeedArtistId("artist123"); + dto.setSeedTrack("track123"); + dto.setSeedGenres("rock"); + dto.setAmount(5); + + ResponseEntity mockResponse = new ResponseEntity<>(MOCK_RESPONSE, HttpStatus.OK); + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // Act + recommendationService.getRecommendations(dto); + + // Verify URL construction + ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); + verify(restTemplate).exchange( + urlCaptor.capture(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + ); + + String capturedUrl = urlCaptor.getValue(); + assertTrue(capturedUrl.startsWith(BASE_URL + "/recommendations")); + assertTrue(capturedUrl.contains("seed_artists=artist123")); + assertTrue(capturedUrl.contains("seed_tracks=track123")); + assertTrue(capturedUrl.contains("seed_genres=rock")); + assertTrue(capturedUrl.contains("limit=5")); + } +} \ No newline at end of file