From 8145cf71ad6075cdeba828177d98408919ec9f7a Mon Sep 17 00:00:00 2001 From: Yana Pechenenko Date: Wed, 10 Jun 2026 00:41:35 +0200 Subject: [PATCH 1/3] implement user statistics endpoint (tests pending) --- postify/pom.xml | 5 +- .../week6/postify/DBConnectionCheck.java | 20 +++ .../controllers/UserStatisticsController.java | 24 ++++ .../response/user/UserBasicInfoResponse.java | 4 + .../response/user/UserStatisticsResponse.java | 13 ++ .../exceptions/GlobalExceptionHandler.java | 15 ++ .../exceptions/UserNotFoundException.java | 7 + .../backend/week6/postify/models/Stream.java | 13 ++ .../week6/postify/models/StreamRowMapper.java | 17 +++ .../repository/UserStatisticsRepository.java | 130 ++++++++++++++++++ .../services/UserStatisticsService.java | 25 ++++ .../src/main/resources/application.properties | 5 +- .../UserStatisticsControllerTest.java | 2 + 13 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/DBConnectionCheck.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsController.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/user/UserBasicInfoResponse.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/user/UserStatisticsResponse.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/GlobalExceptionHandler.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/UserNotFoundException.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/Stream.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/StreamRowMapper.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/UserStatisticsRepository.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/services/UserStatisticsService.java create mode 100644 postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsControllerTest.java diff --git a/postify/pom.xml b/postify/pom.xml index 7cea5f7..44382a5 100644 --- a/postify/pom.xml +++ b/postify/pom.xml @@ -34,7 +34,10 @@ org.springframework.boot spring-boot-starter-webmvc - + + org.springframework.boot + spring-boot-starter-jdbc + org.postgresql postgresql diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/DBConnectionCheck.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/DBConnectionCheck.java new file mode 100644 index 0000000..89c3b4d --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/DBConnectionCheck.java @@ -0,0 +1,20 @@ +package net.hackyourfuture.backend.week6.postify; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; + +public class DBConnectionCheck { + + private final JdbcTemplate jdbcTemplate; + + public DBConnectionCheck(JdbcTemplate jdbcTemplate){ + this.jdbcTemplate = jdbcTemplate; + } + + @EventListener(ApplicationReadyEvent.class) + public void checkConnection(){ + String result = this.jdbcTemplate.queryForObject("SELECT 'connected'", String.class); + System.out.println("Database status: " + result); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsController.java new file mode 100644 index 0000000..72397a2 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsController.java @@ -0,0 +1,24 @@ +package net.hackyourfuture.backend.week6.postify.controllers; + +import net.hackyourfuture.backend.week6.postify.dto.response.user.UserStatisticsResponse; +import net.hackyourfuture.backend.week6.postify.services.UserStatisticsService; +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; + +@RestController +@RequestMapping("/users") +public class UserStatisticsController { + + private final UserStatisticsService service; + + public UserStatisticsController(UserStatisticsService service){ + this.service = service; + } + + @GetMapping("/{id}/stats") + public UserStatisticsResponse getUserStats(@PathVariable int id){ + return service.getStatisticsForUser(id); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/user/UserBasicInfoResponse.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/user/UserBasicInfoResponse.java new file mode 100644 index 0000000..21d1c45 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/user/UserBasicInfoResponse.java @@ -0,0 +1,4 @@ +package net.hackyourfuture.backend.week6.postify.dto.response.user; + +public record UserBasicInfoResponse(int id, String name, String country) { +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/user/UserStatisticsResponse.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/user/UserStatisticsResponse.java new file mode 100644 index 0000000..dfb3cc1 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/user/UserStatisticsResponse.java @@ -0,0 +1,13 @@ +package net.hackyourfuture.backend.week6.postify.dto.response.user; + +public record UserStatisticsResponse( + int userId, + String userName, + String userCountry, + int totalStreams, + int uniqueTracksStreamed, + int uniqueArtistsStreamed, + String favoriteGenre, + int totalListeningTimeSeconds + ) { +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/GlobalExceptionHandler.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/GlobalExceptionHandler.java new file mode 100644 index 0000000..68f7e97 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/GlobalExceptionHandler.java @@ -0,0 +1,15 @@ +package net.hackyourfuture.backend.week6.postify.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleNotFound(UserNotFoundException ex){ + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/UserNotFoundException.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/UserNotFoundException.java new file mode 100644 index 0000000..b44add9 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/UserNotFoundException.java @@ -0,0 +1,7 @@ +package net.hackyourfuture.backend.week6.postify.exceptions; + +public class UserNotFoundException extends RuntimeException{ + public UserNotFoundException(int id){ + super("User with id " + id + " not found"); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/Stream.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/Stream.java new file mode 100644 index 0000000..9a1d1b3 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/Stream.java @@ -0,0 +1,13 @@ +package net.hackyourfuture.backend.week6.postify.models; + +public class Stream { +} + +//public class Stream { +// private int streamId; +// private int userId; +// private int trackId; +// private OffsetDateTime streamedAt; +// +// // constructors, getters, setters +//} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/StreamRowMapper.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/StreamRowMapper.java new file mode 100644 index 0000000..2963af4 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/StreamRowMapper.java @@ -0,0 +1,17 @@ +package net.hackyourfuture.backend.week6.postify.models; + +public class StreamRowMapper { +} + +//public class StreamRowMapper implements RowMapper { +// +// @Override +// public Stream mapRow(ResultSet rs, int rowNum) throws SQLException { +// Stream stream = new Stream(); +// stream.setStreamId(rs.getInt("stream_id")); +// stream.setUserId(rs.getInt("user_id")); +// stream.setTrackId(rs.getInt("track_id")); +// stream.setStreamedAt(rs.getObject("streamed_at", OffsetDateTime.class)); +// return stream; +// } +//} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/UserStatisticsRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/UserStatisticsRepository.java new file mode 100644 index 0000000..e14c6eb --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/UserStatisticsRepository.java @@ -0,0 +1,130 @@ +package net.hackyourfuture.backend.week6.postify.repository; + +import net.hackyourfuture.backend.week6.postify.dto.response.user.UserBasicInfoResponse; +import net.hackyourfuture.backend.week6.postify.dto.response.user.UserStatisticsResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class UserStatisticsRepository { + + private final JdbcTemplate jdbcTemplate; + + public UserStatisticsRepository(JdbcTemplate jdbcTemplate){ + this.jdbcTemplate = jdbcTemplate; + } + + public boolean userExists(int userId){ + String sql = """ + SELECT COUNT(*) + FROM users + WHERE user_id = ? + """; + + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, userId); + return count !=null && count>0; + } + + public Optional fetchUserBasic(int userId){ + String sql = """ + SELECT user_id, user_name, user_country + FROM users + WHERE user_id = ? + """; + + return jdbcTemplate.query(sql, rs -> { + if(rs.next()){ + return Optional.of(new UserBasicInfoResponse( + rs.getInt("user_id"), + rs.getString("user_name"), + rs.getString("user_country") + )); + } + return Optional.empty(); + }, userId); + } + + public int fetchTotalStreams(int userId){ + String sql = """ + SELECT COUNT(*) + FROM streams + WHERE user_id = ? + """; + return jdbcTemplate.queryForObject(sql, Integer.class , userId); + } + + private int fetchUniqueTracks(int userId){ + String sql = """ + SELECT COUNT(DISTINCT track_id) + FROM streams + WHERE user_id = ? + """; + + return jdbcTemplate.queryForObject(sql, Integer.class, userId); + } + + private int fetchUniqueArtists(int userId){ + String sql = """ + SELECT COUNT(DISTINCT a.artist_id) + FROM streams s + JOIN tracks t ON s.track_id = t.track_id + JOIN albums al ON t.album_id = al.album_id + JOIN artists a ON al.artist_id = a.artist_id + WHERE s.user_id = ? + """; + return jdbcTemplate.queryForObject(sql, Integer.class, userId); + } + + private String fetchFavoriteGenre(int userId){ + String sql = """ + SELECT t.genre + FROM streams s + JOIN tracks t ON s.track_id = t.track_id + WHERE user_id = ? + GROUP BY t.genre + ORDER BY COUNT(*) DESC + LIMIT 1 + """; + return jdbcTemplate.query(sql, + rs -> rs.next() ? rs.getString("genre") : null, + userId); + } + + private int fetchTotalListeningTime(int userId){ + String sql = """ + SELECT COALESCE(SUM(t.track_duration_s), 0) + FROM streams s + JOIN tracks t ON s.track_id = t.track_id + WHERE s.user_id = ? + """; + return jdbcTemplate.queryForObject(sql, Integer.class, userId); + } + + public Optional fetchStatistics(int userId){ + + Optional basic = fetchUserBasic(userId); + + if(basic.isEmpty()) return Optional.empty(); + + int totalStreams = fetchTotalStreams(userId); + int uniqueTracks = fetchUniqueTracks(userId); + int uniqueArtists = fetchUniqueArtists(userId); + String favoriteGenre = fetchFavoriteGenre(userId); + int totalListeningTime = fetchTotalListeningTime(userId); + + UserBasicInfoResponse user = basic.get(); + + return Optional.of(new UserStatisticsResponse( + user.id(), + user.name(), + user.country(), + totalStreams, + uniqueTracks, + uniqueArtists, + favoriteGenre, + totalListeningTime + )); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/services/UserStatisticsService.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/services/UserStatisticsService.java new file mode 100644 index 0000000..f1f7d2e --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/services/UserStatisticsService.java @@ -0,0 +1,25 @@ +package net.hackyourfuture.backend.week6.postify.services; + +import net.hackyourfuture.backend.week6.postify.dto.response.user.UserStatisticsResponse; +import net.hackyourfuture.backend.week6.postify.exceptions.UserNotFoundException; +import net.hackyourfuture.backend.week6.postify.repository.UserStatisticsRepository; +import org.springframework.stereotype.Service; + +@Service +public class UserStatisticsService { + + private final UserStatisticsRepository repository; + + public UserStatisticsService(UserStatisticsRepository repository){ + this.repository = repository; + } + + public UserStatisticsResponse getStatisticsForUser(int userId) { + if (!repository.userExists(userId)) { + throw new UserNotFoundException(userId); + } + + return repository.fetchStatistics(userId).orElseThrow(() -> new UserNotFoundException(userId)); + } +} + diff --git a/postify/src/main/resources/application.properties b/postify/src/main/resources/application.properties index 8f4842f..883f75a 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=postgres +spring.datasource.password=postgres +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/controllers/UserStatisticsControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsControllerTest.java new file mode 100644 index 0000000..967bc8c --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsControllerTest.java @@ -0,0 +1,2 @@ +package net.hackyourfuture.backend.week6.postify.controllers; + From 2e90ec2e8193456b1b64fc6b823b5e89be1d9f34 Mon Sep 17 00:00:00 2001 From: Yana Pechenenko Date: Wed, 10 Jun 2026 03:04:37 +0200 Subject: [PATCH 2/3] add dto for lyrics and RestClient --- .../client/LyricsApiConfiguration.java | 18 +++++++++++++ .../week6/postify/client/LyricsClient.java | 25 +++++++++++++++++++ .../dto/response/lyric/LyricApiResponse.java | 4 +++ .../dto/response/lyric/TrackBasicInfo.java | 4 +++ .../response/lyric/TrackLyricResponse.java | 4 +++ 5 files changed, 55 insertions(+) create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiConfiguration.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsClient.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/LyricApiResponse.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackBasicInfo.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackLyricResponse.java diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiConfiguration.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiConfiguration.java new file mode 100644 index 0000000..9fc8e26 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiConfiguration.java @@ -0,0 +1,18 @@ +package net.hackyourfuture.backend.week6.postify.client; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +@Configuration +public class LyricsApiConfiguration { + + @Bean + public RestClient lyricsRestClient(RestClient.Builder builder){ + return builder.baseUrl("https://api.lyrics.ovh") + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsClient.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsClient.java new file mode 100644 index 0000000..acb8d6b --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsClient.java @@ -0,0 +1,25 @@ +package net.hackyourfuture.backend.week6.postify.client; + +import net.hackyourfuture.backend.week6.postify.dto.response.lyric.LyricApiResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +@Service +public class LyricsClient { + private final RestClient lyricsRestClient; + + public LyricsClient(@Qualifier("lyricsRestClient") RestClient lyricsRestClient){ + this.lyricsRestClient = lyricsRestClient; + } + + public LyricApiResponse getLyrics(String artistName, String trackTitle){ + return lyricsRestClient.get() + .uri(uriBuilder -> uriBuilder + .path("/v1/{artist}/{title}") + .build(artistName, trackTitle)) + .retrieve() + .body(LyricApiResponse.class); + + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/LyricApiResponse.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/LyricApiResponse.java new file mode 100644 index 0000000..96b18d9 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/LyricApiResponse.java @@ -0,0 +1,4 @@ +package net.hackyourfuture.backend.week6.postify.dto.response.lyric; + +public record LyricApiResponse(String lyrics) { +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackBasicInfo.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackBasicInfo.java new file mode 100644 index 0000000..b017be3 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackBasicInfo.java @@ -0,0 +1,4 @@ +package net.hackyourfuture.backend.week6.postify.dto.response.lyric; + +public record TrackBasicInfo (int trackId, String trackTitle, String artistName){ +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackLyricResponse.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackLyricResponse.java new file mode 100644 index 0000000..8d358a5 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackLyricResponse.java @@ -0,0 +1,4 @@ +package net.hackyourfuture.backend.week6.postify.dto.response.lyric; + +public record TrackLyricResponse (int trackId, String trackTitle, String artistName, String lyrics){ +} From e3f55c90e81ec37a2353e4a4f5fd9066c5c40884 Mon Sep 17 00:00:00 2001 From: Yana Pechenenko Date: Wed, 10 Jun 2026 14:43:24 +0200 Subject: [PATCH 3/3] add lyrics feature and MockMvc integration tests for lyrics and user stats --- postify/pom.xml | 30 +++++++-- .../client/LyricsApiConfiguration.java | 5 ++ .../week6/postify/client/LyricsClient.java | 17 +++-- .../controllers/TrackLyricsController.java | 23 +++++++ .../response/lyric/TrackLyricResponse.java | 4 +- .../exceptions/GlobalExceptionHandler.java | 12 +++- .../exceptions/LyricsNotFoundException.java | 7 +++ .../exceptions/TrackNotFoundException.java | 7 +++ .../backend/week6/postify/models/Stream.java | 13 ---- .../week6/postify/models/StreamRowMapper.java | 17 ----- .../repository/TracksLyricsRepository.java | 36 +++++++++++ .../postify/services/TrackLyricsService.java | 45 +++++++++++++ .../src/main/resources/application.properties | 2 +- .../postify/PostifyApplicationTests.java | 13 ---- .../TrackLyricsControllerTest.java | 63 +++++++++++++++++++ .../UserStatisticsControllerTest.java | 36 +++++++++++ 16 files changed, 274 insertions(+), 56 deletions(-) create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/controllers/TrackLyricsController.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/LyricsNotFoundException.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/TrackNotFoundException.java delete mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/Stream.java delete mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/StreamRowMapper.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TracksLyricsRepository.java create mode 100644 postify/src/main/java/net/hackyourfuture/backend/week6/postify/services/TrackLyricsService.java delete mode 100644 postify/src/test/java/net/hackyourfuture/backend/week6/postify/PostifyApplicationTests.java create mode 100644 postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/TrackLyricsControllerTest.java diff --git a/postify/pom.xml b/postify/pom.xml index 44382a5..a02f898 100644 --- a/postify/pom.xml +++ b/postify/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.6 + 3.2.5 net.hackyourfuture.backend.week6 @@ -27,12 +27,23 @@ - 25 + 21 + + + + org.testcontainers + testcontainers-bom + 1.19.7 + pom + import + + + org.springframework.boot - spring-boot-starter-webmvc + spring-boot-starter-web org.springframework.boot @@ -48,11 +59,22 @@ lombok true + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + org.springframework.boot - spring-boot-starter-webmvc-test + spring-boot-starter-test test + diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiConfiguration.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiConfiguration.java index 9fc8e26..5a71043 100644 --- a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiConfiguration.java +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsApiConfiguration.java @@ -9,6 +9,11 @@ @Configuration public class LyricsApiConfiguration { + @Bean + public RestClient.Builder restClientBuilder() { + return RestClient.builder(); + } + @Bean public RestClient lyricsRestClient(RestClient.Builder builder){ return builder.baseUrl("https://api.lyrics.ovh") diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsClient.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsClient.java index acb8d6b..5ebdba0 100644 --- a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsClient.java +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/client/LyricsClient.java @@ -3,6 +3,7 @@ import net.hackyourfuture.backend.week6.postify.dto.response.lyric.LyricApiResponse; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; @Service @@ -14,12 +15,16 @@ public LyricsClient(@Qualifier("lyricsRestClient") RestClient lyricsRestClient){ } public LyricApiResponse getLyrics(String artistName, String trackTitle){ - return lyricsRestClient.get() - .uri(uriBuilder -> uriBuilder - .path("/v1/{artist}/{title}") - .build(artistName, trackTitle)) - .retrieve() - .body(LyricApiResponse.class); + try { + return lyricsRestClient.get() + .uri(uriBuilder -> uriBuilder + .path("/v1/{artist}/{title}") + .build(artistName, trackTitle)) + .retrieve() + .body(LyricApiResponse.class); + } catch(HttpClientErrorException.NotFound ex){ + return null; + } } } diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controllers/TrackLyricsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controllers/TrackLyricsController.java new file mode 100644 index 0000000..23e2c52 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controllers/TrackLyricsController.java @@ -0,0 +1,23 @@ +package net.hackyourfuture.backend.week6.postify.controllers; + +import net.hackyourfuture.backend.week6.postify.dto.response.lyric.TrackLyricResponse; +import net.hackyourfuture.backend.week6.postify.services.TrackLyricsService; +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; + +@RestController +@RequestMapping("/tracks") +public class TrackLyricsController { + private final TrackLyricsService service; + + public TrackLyricsController(TrackLyricsService service){ + this.service = service; + } + + @GetMapping("/{id}/lyrics") + public TrackLyricResponse getLyrics(@PathVariable("id") int trackId){ + return service.getLyricsForTrack(trackId); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackLyricResponse.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackLyricResponse.java index 8d358a5..8d3f0ef 100644 --- a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackLyricResponse.java +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/response/lyric/TrackLyricResponse.java @@ -1,4 +1,6 @@ package net.hackyourfuture.backend.week6.postify.dto.response.lyric; -public record TrackLyricResponse (int trackId, String trackTitle, String artistName, String lyrics){ +import java.util.List; + +public record TrackLyricResponse (int trackId, String trackTitle, String artistName, List lyrics){ } diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/GlobalExceptionHandler.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/GlobalExceptionHandler.java index 68f7e97..f7c0118 100644 --- a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/GlobalExceptionHandler.java +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/GlobalExceptionHandler.java @@ -9,7 +9,17 @@ public class GlobalExceptionHandler { @ExceptionHandler(UserNotFoundException.class) - public ResponseEntity handleNotFound(UserNotFoundException ex){ + public ResponseEntity handleUserNotFound(UserNotFoundException ex){ + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); + } + + @ExceptionHandler(TrackNotFoundException.class) + public ResponseEntity handleTrackNotFound(TrackNotFoundException ex){ + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); + } + + @ExceptionHandler(LyricsNotFoundException.class) + public ResponseEntity handleLyricsNotFound(LyricsNotFoundException ex){ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); } } diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/LyricsNotFoundException.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/LyricsNotFoundException.java new file mode 100644 index 0000000..eaec387 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/LyricsNotFoundException.java @@ -0,0 +1,7 @@ +package net.hackyourfuture.backend.week6.postify.exceptions; + +public class LyricsNotFoundException extends RuntimeException{ + public LyricsNotFoundException(String trackTitle, String artistName){ + super("Lyrics not found for track '" + trackTitle + "' by '" + artistName + "'"); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/TrackNotFoundException.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/TrackNotFoundException.java new file mode 100644 index 0000000..2dbf1e3 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exceptions/TrackNotFoundException.java @@ -0,0 +1,7 @@ +package net.hackyourfuture.backend.week6.postify.exceptions; + +public class TrackNotFoundException extends RuntimeException{ + public TrackNotFoundException(int id){ + super("Track with id " + id + " not found"); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/Stream.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/Stream.java deleted file mode 100644 index 9a1d1b3..0000000 --- a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/Stream.java +++ /dev/null @@ -1,13 +0,0 @@ -package net.hackyourfuture.backend.week6.postify.models; - -public class Stream { -} - -//public class Stream { -// private int streamId; -// private int userId; -// private int trackId; -// private OffsetDateTime streamedAt; -// -// // constructors, getters, setters -//} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/StreamRowMapper.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/StreamRowMapper.java deleted file mode 100644 index 2963af4..0000000 --- a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/models/StreamRowMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package net.hackyourfuture.backend.week6.postify.models; - -public class StreamRowMapper { -} - -//public class StreamRowMapper implements RowMapper { -// -// @Override -// public Stream mapRow(ResultSet rs, int rowNum) throws SQLException { -// Stream stream = new Stream(); -// stream.setStreamId(rs.getInt("stream_id")); -// stream.setUserId(rs.getInt("user_id")); -// stream.setTrackId(rs.getInt("track_id")); -// stream.setStreamedAt(rs.getObject("streamed_at", OffsetDateTime.class)); -// return stream; -// } -//} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TracksLyricsRepository.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TracksLyricsRepository.java new file mode 100644 index 0000000..0464caf --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TracksLyricsRepository.java @@ -0,0 +1,36 @@ +package net.hackyourfuture.backend.week6.postify.repository; + +import net.hackyourfuture.backend.week6.postify.dto.response.lyric.TrackBasicInfo; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class TracksLyricsRepository { + private final JdbcTemplate jdbcTemplate; + + public TracksLyricsRepository(JdbcTemplate jdbcTemplate){ + this.jdbcTemplate = jdbcTemplate; + } + + public Optional findTrackWithArtist(int trackId){ + String sql = """ + SELECT t.track_id, t.track_title, a.artist_name + FROM tracks t + JOIN albums al ON t.album_id = al.album_id + JOIN artists a ON al.artist_id = a.artist_id + WHERE t.track_id = ? + """; + + return jdbcTemplate.query(sql,rs -> { + if(rs.next()){ + return Optional.of(new TrackBasicInfo( + rs.getInt("track_id"), + rs.getString("track_title"), + rs.getString("artist_name") + )); + } return Optional.empty(); + }, trackId); + } +} diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/services/TrackLyricsService.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/services/TrackLyricsService.java new file mode 100644 index 0000000..a2753eb --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/services/TrackLyricsService.java @@ -0,0 +1,45 @@ +package net.hackyourfuture.backend.week6.postify.services; + +import net.hackyourfuture.backend.week6.postify.client.LyricsClient; +import net.hackyourfuture.backend.week6.postify.dto.response.lyric.LyricApiResponse; +import net.hackyourfuture.backend.week6.postify.dto.response.lyric.TrackBasicInfo; +import net.hackyourfuture.backend.week6.postify.dto.response.lyric.TrackLyricResponse; +import net.hackyourfuture.backend.week6.postify.exceptions.LyricsNotFoundException; +import net.hackyourfuture.backend.week6.postify.exceptions.TrackNotFoundException; +import net.hackyourfuture.backend.week6.postify.repository.TracksLyricsRepository; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +@Service +public class TrackLyricsService { + private final TracksLyricsRepository repository; + private final LyricsClient lyricsClient; + + public TrackLyricsService(TracksLyricsRepository repository, LyricsClient lyricsClient){ + this.repository = repository; + this.lyricsClient = lyricsClient; + } + + public TrackLyricResponse getLyricsForTrack(int trackId){ + + TrackBasicInfo track = repository.findTrackWithArtist(trackId) + .orElseThrow(() -> new TrackNotFoundException(trackId)); + + LyricApiResponse apiResponse = lyricsClient.getLyrics(track.artistName(), track.trackTitle()); + + if (apiResponse == null || apiResponse.lyrics() == null || apiResponse.lyrics().isBlank()){ + throw new LyricsNotFoundException(track.trackTitle(), track.artistName()); + } + + List lines = Arrays.asList(apiResponse.lyrics().split("\n")); + + return new TrackLyricResponse( + track.trackId(), + track.trackTitle(), + track.artistName(), + lines + ); + } +} diff --git a/postify/src/main/resources/application.properties b/postify/src/main/resources/application.properties index 883f75a..6792daf 100644 --- a/postify/src/main/resources/application.properties +++ b/postify/src/main/resources/application.properties @@ -1,4 +1,4 @@ spring.datasource.url=jdbc:postgresql://localhost:5432/postify spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.driver-class-name=org.postgresql.Driver \ No newline at end of file +spring.datasource.driver-class-name=org.postgresql.Driver diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/PostifyApplicationTests.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/PostifyApplicationTests.java deleted file mode 100644 index f5ec046..0000000 --- a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/PostifyApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package net.hackyourfuture.backend.week6.postify; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class PostifyApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/TrackLyricsControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/TrackLyricsControllerTest.java new file mode 100644 index 0000000..17d64d6 --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/TrackLyricsControllerTest.java @@ -0,0 +1,63 @@ +package net.hackyourfuture.backend.week6.postify.controllers; + +import net.hackyourfuture.backend.week6.postify.client.LyricsClient; +import net.hackyourfuture.backend.week6.postify.dto.response.lyric.LyricApiResponse; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +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.*; + +@SpringBootTest +@AutoConfigureMockMvc +class TrackLyricsControllerTest{ + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LyricsClient lyricsClient; + + @Test + void getLyrics_existingTrack_returnsLyrics() throws Exception{ + + when(lyricsClient.getLyrics("Billie Eilish", "LUNCH")) + .thenReturn(new LyricApiResponse(""" + Line 1 + Line 2 + """)); + + mockMvc.perform(get("/tracks/41/lyrics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.trackId").value(41)) + .andExpect(jsonPath("$.trackTitle").value("LUNCH")) + .andExpect(jsonPath("$.artistName").value("Billie Eilish")) + .andExpect(jsonPath("$.lyrics[0]").value("Line 1")) + .andExpect(jsonPath("$.lyrics[1]").value("Line 2")); + + + } + + @Test + void getLyrics_nonExistingTrack_returns404() throws Exception { + mockMvc.perform(get("/tracks/9999/lyrics")) + .andExpect(status().isNotFound()) + .andExpect(content().string("Track with id 9999 not found")); + } + + @Test + void getLyrics_existingTrackButNoLyrics_returns404() throws Exception { + + when(lyricsClient.getLyrics("Billie Eilish", "LUNCH")) + .thenReturn(new LyricApiResponse("")); + + mockMvc.perform(get("/tracks/41/lyrics")) + .andExpect(status().isNotFound()) + .andExpect(content().string( "Lyrics not found for track 'LUNCH' by 'Billie Eilish'")); + } +} diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsControllerTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsControllerTest.java index 967bc8c..984dc77 100644 --- a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsControllerTest.java +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/controllers/UserStatisticsControllerTest.java @@ -1,2 +1,38 @@ package net.hackyourfuture.backend.week6.postify.controllers; + + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +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.*; + +@SpringBootTest +@AutoConfigureMockMvc +class UserStatisticsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getUserStats_existingUser_returnsOkAndBody() 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")) + .andExpect(jsonPath("$.totalStreams").isNumber()); + } + + @Test + void getUserStats_nonExistingUser_returns404() throws Exception { + mockMvc.perform(get("/users/9999/stats")) + .andExpect(status().isNotFound()) + .andExpect(content().string("User with id 9999 not found")); + + } +} \ No newline at end of file