diff --git a/postify/pom.xml b/postify/pom.xml index 7cea5f7..ea2678e 100644 --- a/postify/pom.xml +++ b/postify/pom.xml @@ -34,6 +34,10 @@ org.springframework.boot spring-boot-starter-webmvc + + org.springframework.boot + spring-boot-starter-jdbc + org.postgresql diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/LyricsClient.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/LyricsClient.java new file mode 100644 index 0000000..6afb3bb --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/LyricsClient.java @@ -0,0 +1,44 @@ +package net.hackyourfuture.backend.week6.postify.tracks; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.Optional; + +@Component +public class LyricsClient { + + private final RestClient restClient; + + public LyricsClient(@Value("${postify.lyrics-api.base-url}") String baseUrl) { + this.restClient = RestClient.builder().baseUrl(baseUrl).build(); + } + + public Optional fetchLyrics(String artistName, String trackTitle) { + return restClient.get() + .uri("/v1/{artist}/{title}", artistName, trackTitle) + .exchange((request, response) -> { + if (response.getStatusCode().value() == 404) { + return Optional.empty(); + } + if (response.getStatusCode().isError()) { + throw new LyricsApiException( + "Lyrics API returned " + response.getStatusCode()); + } + LyricsResponse body = response.bodyTo(LyricsResponse.class); + if (body == null || body.lyrics() == null || body.lyrics().isBlank()) { + return Optional.empty(); + } + return Optional.of(body.lyrics()); + }); + } + + private record LyricsResponse(String lyrics) {} + + public static class LyricsApiException extends RuntimeException { + public LyricsApiException(String message) { + super(message); + } + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackInfo.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackInfo.java new file mode 100644 index 0000000..2199670 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackInfo.java @@ -0,0 +1,3 @@ +package net.hackyourfuture.backend.week6.postify.tracks; + +public record TrackInfo(int trackId, String trackTitle, String artistName) {} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyrics.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyrics.java new file mode 100644 index 0000000..3a48a81 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyrics.java @@ -0,0 +1,8 @@ +package net.hackyourfuture.backend.week6.postify.tracks; + +public record TrackLyrics( + int trackId, + String trackTitle, + String artistName, + String lyrics +) {} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsController.java new file mode 100644 index 0000000..9dcad17 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsController.java @@ -0,0 +1,34 @@ +package net.hackyourfuture.backend.week6.postify.tracks; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +public class TrackLyricsController { + + private final TrackRepository trackRepository; + private final LyricsClient lyricsClient; + + public TrackLyricsController(TrackRepository trackRepository, LyricsClient lyricsClient) { + this.trackRepository = trackRepository; + this.lyricsClient = lyricsClient; + } + + @GetMapping("/tracks/{id}/lyrics") + public TrackLyrics getTrackLyrics(@PathVariable int id) { + TrackInfo track = trackRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "Track " + id + " does not exist")); + + String lyrics = lyricsClient.fetchLyrics(track.artistName(), track.trackTitle()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "No lyrics available for '" + track.trackTitle() + + "' by " + track.artistName())); + + return new TrackLyrics(track.trackId(), track.trackTitle(), track.artistName(), lyrics); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackRepository.java new file mode 100644 index 0000000..5cbf495 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/tracks/TrackRepository.java @@ -0,0 +1,32 @@ +package net.hackyourfuture.backend.week6.postify.tracks; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class TrackRepository { + + private final JdbcClient jdbc; + + public TrackRepository(JdbcClient jdbc) { + this.jdbc = jdbc; + } + + public Optional findById(int trackId) { + return jdbc.sql(""" + SELECT t.track_id, t.track_title, ar.artist_name + FROM tracks t + JOIN albums al ON t.album_id = al.album_id + JOIN artists ar ON al.artist_id = ar.artist_id + WHERE t.track_id = :id + """) + .param("id", trackId) + .query((rs, rowNum) -> new TrackInfo( + rs.getInt("track_id"), + rs.getString("track_title"), + rs.getString("artist_name"))) + .optional(); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStats.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStats.java new file mode 100644 index 0000000..0c9ec40 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStats.java @@ -0,0 +1,12 @@ +package net.hackyourfuture.backend.week6.postify.users; + +public record UserStats( + int userId, + String userName, + String userCountry, + long totalStreams, + long uniqueTracksStreamed, + long uniqueArtistsStreamed, + String favoriteGenre, + long totalListeningTimeSeconds +) {} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsController.java new file mode 100644 index 0000000..ae7ff33 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsController.java @@ -0,0 +1,23 @@ +package net.hackyourfuture.backend.week6.postify.users; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +public class UserStatsController { + + private final UserStatsRepository repository; + + public UserStatsController(UserStatsRepository repository) { + this.repository = repository; + } + + @GetMapping("/users/{id}/stats") + public UserStats getUserStats(@PathVariable int id) { + return repository.findStatsByUserId(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsRepository.java new file mode 100644 index 0000000..8dd3242 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/users/UserStatsRepository.java @@ -0,0 +1,74 @@ +package net.hackyourfuture.backend.week6.postify.users; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class UserStatsRepository { + + private final JdbcClient jdbc; + + public UserStatsRepository(JdbcClient jdbc) { + this.jdbc = jdbc; + } + + public Optional findStatsByUserId(int userId) { + // Query 1: confirm the user exists and grab profile fields. + var profile = jdbc.sql(""" + SELECT user_id, user_name, user_country + FROM users + WHERE user_id = :id + """) + .param("id", userId) + .query((rs, rowNum) -> new UserProfile( + rs.getInt("user_id"), + rs.getString("user_name"), + rs.getString("user_country"))) + .optional(); + + if (profile.isEmpty()) { + return Optional.empty(); + } + + // Query 2: aggregate listening stats across streams -> tracks -> albums -> artists. + // favoriteGenre is computed via a correlated scalar subquery with GROUP BY + LIMIT 1. + UserProfile p = profile.get(); + UserStats stats = jdbc.sql(""" + SELECT + COUNT(*) AS total_streams, + COUNT(DISTINCT t.track_id) AS unique_tracks, + COUNT(DISTINCT al.artist_id) AS unique_artists, + COALESCE(SUM(t.track_duration_s), 0) AS total_seconds, + ( + SELECT t2.genre + FROM streams s2 + JOIN tracks t2 ON s2.track_id = t2.track_id + WHERE s2.user_id = :id AND t2.genre IS NOT NULL + GROUP BY t2.genre + ORDER BY COUNT(*) DESC + LIMIT 1 + ) AS favorite_genre + FROM streams s + JOIN tracks t ON s.track_id = t.track_id + JOIN albums al ON t.album_id = al.album_id + WHERE s.user_id = :id + """) + .param("id", userId) + .query((rs, rowNum) -> new UserStats( + p.userId(), + p.userName(), + p.userCountry(), + rs.getLong("total_streams"), + rs.getLong("unique_tracks"), + rs.getLong("unique_artists"), + rs.getString("favorite_genre"), + rs.getLong("total_seconds"))) + .single(); + + return Optional.of(stats); + } + + private record UserProfile(int userId, String userName, String userCountry) {} +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/web/ApiExceptionHandler.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/web/ApiExceptionHandler.java new file mode 100644 index 0000000..645a4e7 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/web/ApiExceptionHandler.java @@ -0,0 +1,22 @@ +package net.hackyourfuture.backend.week6.postify.web; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; + +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { + return ResponseEntity + .status(ex.getStatusCode()) + .body(Map.of( + "status", ex.getStatusCode().value(), + "message", ex.getReason() == null ? "" : ex.getReason() + )); + } +} diff --git a/postify/src/main/resources/application.properties b/postify/src/main/resources/application.properties index 8f4842f..d6bbac8 100644 --- a/postify/src/main/resources/application.properties +++ b/postify/src/main/resources/application.properties @@ -1 +1,10 @@ spring.application.name=postify + +spring.datasource.url=jdbc:postgresql://localhost:5432/postify +spring.datasource.username=hyfuser +spring.datasource.password=hyfpassword +spring.datasource.driver-class-name=org.postgresql.Driver + +postify.lyrics-api.base-url=https://api.lyrics.ovh + +spring.web.error.include-message=always diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsControllerTest.java new file mode 100644 index 0000000..c53ed87 --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/tracks/TrackLyricsControllerTest.java @@ -0,0 +1,64 @@ +package net.hackyourfuture.backend.week6.postify.tracks; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; + +import java.util.Optional; + +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; + +@WebMvcTest(TrackLyricsController.class) +class TrackLyricsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TrackRepository trackRepository; + + @MockitoBean + private LyricsClient lyricsClient; + + @Test + void returnsLyricsForExistingTrack() throws Exception { + TrackInfo track = new TrackInfo(41, "LUNCH", "Billie Eilish"); + when(trackRepository.findById(41)).thenReturn(Optional.of(track)); + when(lyricsClient.fetchLyrics("Billie Eilish", "LUNCH")) + .thenReturn(Optional.of("I could eat that girl for lunch...")); + + mockMvc.perform(get("/tracks/{id}/lyrics", 41)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.trackId").value(41)) + .andExpect(jsonPath("$.trackTitle").value("LUNCH")) + .andExpect(jsonPath("$.artistName").value("Billie Eilish")) + .andExpect(jsonPath("$.lyrics").value("I could eat that girl for lunch...")); + } + + @Test + void returns404WhenTrackDoesNotExist() throws Exception { + when(trackRepository.findById(9999)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/tracks/{id}/lyrics", 9999)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Track 9999 does not exist")); + } + + @Test + void returns404WhenLyricsApiHasNoLyrics() throws Exception { + TrackInfo track = new TrackInfo(7, "Some Dutch Song", "Een Artiest"); + when(trackRepository.findById(7)).thenReturn(Optional.of(track)); + when(lyricsClient.fetchLyrics("Een Artiest", "Some Dutch Song")) + .thenReturn(Optional.empty()); + + mockMvc.perform(get("/tracks/{id}/lyrics", 7)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message") + .value("No lyrics available for 'Some Dutch Song' by Een Artiest")); + } +} diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/users/UserStatsControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/users/UserStatsControllerTest.java new file mode 100644 index 0000000..06f7e70 --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/users/UserStatsControllerTest.java @@ -0,0 +1,55 @@ +package net.hackyourfuture.backend.week6.postify.users; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +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; + +@WebMvcTest(UserStatsController.class) +class UserStatsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserStatsRepository repository; + + @Test + void returnsUserStatsForExistingUser() throws Exception { + UserStats stats = new UserStats( + 1, + "lena_v", + "NL", + 65, + 34, + 8, + "Nederpop", + 14021 + ); + when(repository.findStatsByUserId(1)).thenReturn(Optional.of(stats)); + + mockMvc.perform(get("/users/{id}/stats", 1)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(1)) + .andExpect(jsonPath("$.userName").value("lena_v")) + .andExpect(jsonPath("$.totalStreams").value(65)) + .andExpect(jsonPath("$.favoriteGenre").value("Nederpop")) + .andExpect(jsonPath("$.totalListeningTimeSeconds").value(14021)); + } + + @Test + void returns404WhenUserDoesNotExist() throws Exception { + when(repository.findStatsByUserId(9999)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/users/{id}/stats", 9999)) + .andExpect(status().isNotFound()); + } +}