diff --git a/postify/pom.xml b/postify/pom.xml index 7cea5f7..abf0fba 100644 --- a/postify/pom.xml +++ b/postify/pom.xml @@ -34,12 +34,6 @@ org.springframework.boot spring-boot-starter-webmvc - - - org.postgresql - postgresql - runtime - org.projectlombok lombok @@ -50,6 +44,25 @@ spring-boot-starter-webmvc-test test + + + org.springframework.boot + spring-boot-starter-jdbc + + + + + org.postgresql + postgresql + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/config/RestClientConfig.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/config/RestClientConfig.java new file mode 100644 index 0000000..f213438 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/config/RestClientConfig.java @@ -0,0 +1,14 @@ +package net.hackyourfuture.backend.week6.postify.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + return RestClient.create(); + } +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/TrackController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/TrackController.java new file mode 100644 index 0000000..c4b375c --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/TrackController.java @@ -0,0 +1,23 @@ +package net.hackyourfuture.backend.week6.postify.controller; + + +import lombok.AllArgsConstructor; +import net.hackyourfuture.backend.week6.postify.dto.TrackLyricsResponse; +import net.hackyourfuture.backend.week6.postify.service.TrackService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("/tracks") +public class TrackController { + + private final TrackService trackService; + + @GetMapping("/{id}/lyrics") + public TrackLyricsResponse getTrackLyrics(@PathVariable Long id) { + return trackService.getTrackLyrics(id); + } +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserController.java new file mode 100644 index 0000000..6f1b34c --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserController.java @@ -0,0 +1,34 @@ +package net.hackyourfuture.backend.week6.postify.controller; + + +import lombok.AllArgsConstructor; +import net.hackyourfuture.backend.week6.postify.dto.UserStatsResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import net.hackyourfuture.backend.week6.postify.repository.UserRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +@AllArgsConstructor +@RestController +@RequestMapping("/users") +public class UserController { + + private final UserRepository userRepository; + + @GetMapping("/{id}/stats") + public ResponseEntity getUserStatsById(@PathVariable Long id) { + UserStatsResponse stats = userRepository.getUserStatsById(id); + + return stats != null ? ResponseEntity.ok(stats) + : ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found"); + + } + + +} + + diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/ExternalLyricsResponse.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/ExternalLyricsResponse.java new file mode 100644 index 0000000..2d56f43 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/ExternalLyricsResponse.java @@ -0,0 +1,10 @@ +package net.hackyourfuture.backend.week6.postify.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ExternalLyricsResponse { + private String lyrics; +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyricsResponse.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyricsResponse.java new file mode 100644 index 0000000..e86c363 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyricsResponse.java @@ -0,0 +1,15 @@ +package net.hackyourfuture.backend.week6.postify.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class TrackLyricsResponse { + private Long trackId; + private String trackTitle; + private String artistName; + private String lyrics; +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatsResponse.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatsResponse.java new file mode 100644 index 0000000..8d342b0 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatsResponse.java @@ -0,0 +1,19 @@ +package net.hackyourfuture.backend.week6.postify.dto; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + + +@Getter +@AllArgsConstructor +public class UserStatsResponse { + private final Long userId; + private final String userName; + private final String userCountry; + private final Long totalStreams; + private final Long uniqueTracksStreamed; + private final Long uniqueArtistsStreamed; + private final String favoriteGenre; + private final Long totalListeningTimeSeconds; +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TrackRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TrackRepository.java new file mode 100644 index 0000000..2e147a1 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TrackRepository.java @@ -0,0 +1,41 @@ +package net.hackyourfuture.backend.week6.postify.repository; + + +import net.hackyourfuture.backend.week6.postify.dto.TrackLyricsResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class TrackRepository { + + private final JdbcTemplate jdbcTemplate; + + public TrackRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public TrackLyricsResponse getTrackAndArtistById(Long trackId) { + String sql = "SELECT " + + " tracks.track_id AS trackId, " + + " tracks.track_title AS trackTitle, " + + " artists.artist_name AS artistName " + + "FROM tracks " + + "JOIN albums ON tracks.album_id = albums.album_id " + + "JOIN artists ON albums.artist_id = artists.artist_id " + + "WHERE tracks.track_id = ?;"; + + try { + return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> + new TrackLyricsResponse( + rs.getLong("trackId"), + rs.getString("trackTitle"), + rs.getString("artistName"), + null // from external API + ), + trackId + ); + } catch (Exception e) { + return null; + } + } +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/UserRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/UserRepository.java new file mode 100644 index 0000000..595dd02 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/UserRepository.java @@ -0,0 +1,72 @@ +package net.hackyourfuture.backend.week6.postify.repository; + +import net.hackyourfuture.backend.week6.postify.dto.UserStatsResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class UserRepository { + + private final JdbcTemplate jdbcTemplate; + + public UserRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public UserStatsResponse getUserStatsById(Long id) { + + String mainSql = "SELECT " + + " users.user_id AS userId, " + + " users.user_name AS userName, " + + " users.user_country AS userCountry, " + + " COUNT(streams.stream_id) AS totalStreams, " + + " COUNT(DISTINCT streams.track_id) AS uniqueTracksStreamed, " + + " COUNT(DISTINCT albums.artist_id) AS uniqueArtistsStreamed, " + + " COALESCE(SUM(tracks.track_duration_s), 0) AS totalListeningTimeSeconds " + + "FROM users " + + "LEFT JOIN streams ON users.user_id = streams.user_id " + + "LEFT JOIN tracks ON streams.track_id = tracks.track_id " + + "LEFT JOIN albums ON tracks.album_id = albums.album_id " + + "WHERE users.user_id = ? " + + "GROUP BY users.user_id, users.user_name, users.user_country;"; + + String genreSql = "SELECT tracks.genre " + + "FROM streams " + + "JOIN tracks ON streams.track_id = tracks.track_id " + + "WHERE streams.user_id = ? " + + "GROUP BY tracks.genre " + + "ORDER BY COUNT(streams.stream_id) DESC " + + "LIMIT 1;"; + + try { + String favoriteGenre; + try { + favoriteGenre = jdbcTemplate.queryForObject(genreSql, String.class, id); + if (favoriteGenre == null) { + favoriteGenre = "None"; + } + } catch (Exception e) { + favoriteGenre = "None"; + } + + final String finalGenre = favoriteGenre; + + return jdbcTemplate.queryForObject(mainSql, (rs, rowNum) -> + new UserStatsResponse( + rs.getLong("userId"), + rs.getString("userName"), + rs.getString("userCountry"), + rs.getLong("totalStreams"), + rs.getLong("uniqueTracksStreamed"), + rs.getLong("uniqueArtistsStreamed"), + finalGenre, + rs.getLong("totalListeningTimeSeconds") + ), + id + ); + + } catch (Exception e) { + return null; + } + } +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackService.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackService.java new file mode 100644 index 0000000..fab1b56 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackService.java @@ -0,0 +1,58 @@ +package net.hackyourfuture.backend.week6.postify.service; + + +import net.hackyourfuture.backend.week6.postify.dto.ExternalLyricsResponse; +import net.hackyourfuture.backend.week6.postify.dto.TrackLyricsResponse; +import net.hackyourfuture.backend.week6.postify.repository.TrackRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class TrackService { + + private final TrackRepository trackRepository; + private final RestClient restClient; + + public TrackService(TrackRepository trackRepository, RestClient restClient) { + this.trackRepository = trackRepository; + this.restClient = restClient; + } + + public TrackLyricsResponse getTrackLyrics(Long trackId) { + + TrackLyricsResponse trackInfo = trackRepository.getTrackAndArtistById(trackId); + + if (trackInfo == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Track not found in our database"); + } + + + String url = "https://api.lyrics.ovh/v1/" + trackInfo.getArtistName() + "/" + trackInfo.getTrackTitle(); + + try { + + ExternalLyricsResponse externalResponse = restClient.get() + .uri(url) + .retrieve() + .body(ExternalLyricsResponse.class); + + + if (externalResponse != null && externalResponse.getLyrics() != null) { + return new TrackLyricsResponse( + trackInfo.getTrackId(), + trackInfo.getTrackTitle(), + trackInfo.getArtistName(), + externalResponse.getLyrics() + ); + } else { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Lyrics not found for this track"); + } + + } catch (Exception e) { + + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "The lyrics API has no lyrics for this track."); + } + } +} \ No newline at end of file diff --git a/postify/src/main/resources/application.properties b/postify/src/main/resources/application.properties index 8f4842f..fc3ab0d 100644 --- a/postify/src/main/resources/application.properties +++ b/postify/src/main/resources/application.properties @@ -1 +1,4 @@ -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 \ No newline at end of file diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controller/TrackControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controller/TrackControllerTest.java new file mode 100644 index 0000000..89791c5 --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controller/TrackControllerTest.java @@ -0,0 +1,74 @@ +package net.hackyourfuture.backend.week6.postify.controller; + +import net.hackyourfuture.backend.week6.postify.dto.TrackLyricsResponse; +import net.hackyourfuture.backend.week6.postify.service.TrackService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +class TrackControllerTest { + + @Autowired + private MockMvc mockMvc; + + + @MockitoBean + private TrackService trackService; + + + @Test + void shouldReturnLyricsForExistingTrack() throws Exception { + + TrackLyricsResponse fakeResponse = new TrackLyricsResponse( + 67L, + "the 1", + "Taylor Swift", + "I'm doing good, I'm on some new shi..." + ); + + + when(trackService.getTrackLyrics(67L)).thenReturn(fakeResponse); + + mockMvc.perform(get("/tracks/67/lyrics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.trackId").value(67L)) + .andExpect(jsonPath("$.trackTitle").value("the 1")) + .andExpect(jsonPath("$.artistName").value("Taylor Swift")) + .andExpect(jsonPath("$.lyrics").value("I'm doing good, I'm on some new shi...")); + } + + + @Test + void shouldReturn404WhenTrackNotFound() throws Exception { + + when(trackService.getTrackLyrics(99999L)) + .thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND)); + + mockMvc.perform(get("/tracks/99999/lyrics")) + .andExpect(status().isNotFound()); + } + + + @Test + void shouldReturn404WhenLyricsNotAvailable() throws Exception { + + when(trackService.getTrackLyrics(90L)) + .thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND)); + + mockMvc.perform(get("/tracks/90/lyrics")) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controller/UserControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controller/UserControllerTest.java new file mode 100644 index 0000000..4f1f86f --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controller/UserControllerTest.java @@ -0,0 +1,36 @@ +package net.hackyourfuture.backend.week6.postify.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldReturnUserStats() throws Exception { + + mockMvc.perform(get("/users/1/stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(1)) + .andExpect(jsonPath("$.userName").value("lena_v")) + .andExpect(jsonPath("$.userCountry").value("NL")); + } + + @Test + void shouldReturn404WhenUserNotFound() throws Exception { + + mockMvc.perform(get("/users/99999/stats")) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file