-
Notifications
You must be signed in to change notification settings - Fork 7
Monera A #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Monera A #3
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| 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 WebClientConfig { | ||
|
|
||
| @Bean | ||
| public RestClient lyricsRestClient() { | ||
| return RestClient.builder() | ||
| .baseUrl("https://api.lyrics.ovh/v1") | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package net.hackyourfuture.backend.week6.postify.controller; | ||
|
|
||
| import net.hackyourfuture.backend.week6.postify.dto.TrackLyricsDto; | ||
| import net.hackyourfuture.backend.week6.postify.service.TrackLyricsService; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| public class TrackController { | ||
|
|
||
| private final TrackLyricsService trackLyricsService; | ||
|
|
||
| public TrackController(TrackLyricsService trackLyricsService) { | ||
| this.trackLyricsService = trackLyricsService; | ||
| } | ||
|
|
||
| @GetMapping("/tracks/{id}/lyrics") | ||
| public TrackLyricsDto getLyrics(@PathVariable("id") Long id) { | ||
| return trackLyricsService.getTrackLyrics(id); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package net.hackyourfuture.backend.week6.postify.controller; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import net.hackyourfuture.backend.week6.postify.dto.UserStatsDto; | ||
| import net.hackyourfuture.backend.week6.postify.service.UserStatsService; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| public class UserStatsController { | ||
|
|
||
| private final UserStatsService userStatsService; | ||
|
|
||
| @GetMapping("/users/{id}/stats") | ||
| public UserStatsDto getUserStats(@PathVariable("id") Integer id) { | ||
| return userStatsService.getUserStats(id); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package net.hackyourfuture.backend.week6.postify.dto; | ||
|
|
||
| public record ExternalLyricsResponse(String lyrics) { | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package net.hackyourfuture.backend.week6.postify.dto; | ||
|
|
||
| public class TrackLyricsDto { | ||
| private Long trackId; | ||
| private String trackTitle; | ||
| private String artistName; | ||
| private String lyrics; | ||
|
|
||
| public TrackLyricsDto(Long trackId, String trackTitle, String artistName, String lyrics) { | ||
| this.trackId = trackId; | ||
| this.trackTitle = trackTitle; | ||
| this.artistName = artistName; | ||
| this.lyrics = lyrics; | ||
| } | ||
|
|
||
| // Getters and Setters | ||
| public Long getTrackId() { return trackId; } | ||
| public void setTrackId(Long trackId) { this.trackId = trackId; } | ||
| public String getTrackTitle() { return trackTitle; } | ||
| public void setTrackTitle(String trackTitle) { this.trackTitle = trackTitle; } | ||
| public String getArtistName() { return artistName; } | ||
| public void setArtistName(String artistName) { this.artistName = artistName; } | ||
| public String getLyrics() { return lyrics; } | ||
| public void setLyrics(String lyrics) { this.lyrics = lyrics; } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package net.hackyourfuture.backend.week6.postify.dto; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| public class UserStatsDto { | ||
| private Integer userId; | ||
| private String userName; | ||
| private String userCountry; | ||
| private Long totalStreams; | ||
| private Long uniqueTracksStreamed; | ||
| private Long uniqueArtistsStreamed; | ||
| private Long totalListeningTimeSeconds; | ||
| private String favoriteGenre; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package net.hackyourfuture.backend.week6.postify.exception; | ||
|
|
||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
| import org.springframework.web.client.HttpClientErrorException; | ||
| import java.util.Map; | ||
|
|
||
| @RestControllerAdvice | ||
| public class GlobalExceptionHandler { | ||
|
|
||
| // Catches any 404 errors thrown by RestClient when the external lyrics API comes up empty | ||
| @ExceptionHandler(HttpClientErrorException.NotFound.class) | ||
| public ResponseEntity<Map<String, String>> handleExternalLyricsNotFound(HttpClientErrorException.NotFound ex) { | ||
| return ResponseEntity | ||
| .status(HttpStatus.NOT_FOUND) | ||
| .body(Map.of("error", "The lyrics API does not have any lyrics available for this song.")); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package net.hackyourfuture.backend.week6.postify.repository; | ||
|
|
||
| import net.hackyourfuture.backend.week6.postify.dto.TrackLyricsDto; | ||
| import org.springframework.dao.EmptyResultDataAccessException; | ||
| import org.springframework.jdbc.core.JdbcTemplate; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| @Repository | ||
| public class TrackRepository { | ||
|
|
||
| private final JdbcTemplate jdbcTemplate; | ||
|
|
||
| public TrackRepository(JdbcTemplate jdbcTemplate) { | ||
| this.jdbcTemplate = jdbcTemplate; | ||
| } | ||
|
|
||
| public Optional<TrackLyricsDto> findTrackAndArtistById(Long 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 = ? | ||
| """; | ||
|
|
||
| try { | ||
| // If found, wrap the object inside a safe Optional box | ||
| TrackLyricsDto dto = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> | ||
| new TrackLyricsDto( | ||
| rs.getLong("track_id"), | ||
| rs.getString("track_title"), | ||
| rs.getString("artist_name"), | ||
| null // Lyrics are left empty for now | ||
| ), trackId); | ||
| return Optional.ofNullable(dto); | ||
|
|
||
| } catch (EmptyResultDataAccessException e) { | ||
| // If the database finds nothing, return a safe empty box | ||
| return Optional.empty(); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package net.hackyourfuture.backend.week6.postify.repository; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import net.hackyourfuture.backend.week6.postify.dto.UserStatsDto; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import javax.sql.DataSource; | ||
| import java.sql.Connection; | ||
| import java.sql.PreparedStatement; | ||
| import java.sql.ResultSet; | ||
| import java.sql.SQLException; | ||
| import java.util.Optional; | ||
|
|
||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class UserRepository { | ||
|
|
||
| private final DataSource dataSource; | ||
|
|
||
| public Optional<UserStatsDto> getUserStats(Integer userId) { | ||
| String sql = """ | ||
| SELECT | ||
| u.user_id, | ||
| u.user_name, | ||
| u.user_country, | ||
| COUNT(s.stream_id) AS total_streams, | ||
| COUNT(DISTINCT s.track_id) AS unique_tracks, | ||
| COUNT(DISTINCT al.artist_id) AS unique_artists, | ||
| COALESCE(SUM(t.track_duration_s), 0) AS total_time, | ||
| ( | ||
| SELECT t2.genre | ||
| FROM streams s2 | ||
| JOIN tracks t2 ON s2.track_id = t2.track_id | ||
| WHERE s2.user_id = u.user_id | ||
| GROUP BY t2.genre | ||
| ORDER BY COUNT(s2.stream_id) DESC, t2.genre ASC | ||
| LIMIT 1 | ||
| ) AS favorite_genre | ||
| FROM users u | ||
| LEFT JOIN streams s ON u.user_id = s.user_id | ||
| LEFT JOIN tracks t ON s.track_id = t.track_id | ||
| LEFT JOIN albums al ON t.album_id = al.album_id | ||
| WHERE u.user_id = ? | ||
| GROUP BY u.user_id, u.user_name, u.user_country; | ||
| """; | ||
|
|
||
| try (Connection connection = dataSource.getConnection(); | ||
| PreparedStatement statement = connection.prepareStatement(sql)) { | ||
|
|
||
| statement.setInt(1, userId); | ||
|
|
||
| try (ResultSet resultSet = statement.executeQuery()) { | ||
| if (resultSet.next()) { | ||
| if (resultSet.getString("user_name") == null) { | ||
| return Optional.empty(); | ||
| } | ||
|
|
||
| UserStatsDto dto = new UserStatsDto( | ||
| resultSet.getInt("user_id"), | ||
| resultSet.getString("user_name"), | ||
| resultSet.getString("user_country"), | ||
| resultSet.getLong("total_streams"), | ||
| resultSet.getLong("unique_tracks"), | ||
| resultSet.getLong("unique_artists"), | ||
| resultSet.getLong("total_time"), | ||
| resultSet.getString("favorite_genre") | ||
| ); | ||
| return Optional.of(dto); | ||
| } | ||
| } | ||
| } catch (SQLException e) { | ||
| throw new RuntimeException("Database error occurred while fetching user statistics", e); | ||
| } | ||
|
|
||
| return Optional.empty(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package net.hackyourfuture.backend.week6.postify.service; | ||
|
|
||
| import net.hackyourfuture.backend.week6.postify.dto.ExternalLyricsResponse; | ||
| import net.hackyourfuture.backend.week6.postify.dto.TrackLyricsDto; | ||
| import net.hackyourfuture.backend.week6.postify.repository.TrackRepository; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.web.client.HttpClientErrorException; | ||
| import org.springframework.web.client.RestClient; | ||
| import org.springframework.web.server.ResponseStatusException; | ||
|
|
||
| @Service | ||
| public class TrackLyricsService { | ||
|
|
||
| private final TrackRepository trackRepository; | ||
| private final RestClient restClient; | ||
|
|
||
| public TrackLyricsService(TrackRepository trackRepository) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is completely optional and just for future reference, feel free to skip it for now. In case you'd like to write @RequiredArgsConstructor
public class TrackLyricsService {
private final TrackRepository trackRepository;
@Qualifier("lyricsRestClient") private final RestClient restClient;However, Lombok then needs to copy that annotation to the generated constructor and it doesn't do that by default. You can configure Lombok to do that by adding the line below to a |
||
| this.trackRepository = trackRepository; | ||
| // Sets up the RestClient base URL pointing directly to the external lyrics provider | ||
| this.restClient = RestClient.builder() | ||
| .baseUrl("https://api.lyrics.ovh/v1") | ||
| .build(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both for performance and ease of testing it is best to declare a public TrackLyricsService(TrackRepository trackRepository, @Qualifier("lyricsRestClient") RestClient restClient) {
this.trackRepository = trackRepository;
this.restClient = restClient;
}If you're hitting application context startup issues, that can be because multiple beans with the |
||
| } | ||
|
|
||
| public TrackLyricsDto getTrackLyrics(Long trackId) { | ||
| // 1. Check the DB for the track. If the Optional box is empty, instantly throw a 404. | ||
| TrackLyricsDto dto = trackRepository.findTrackAndArtistById(trackId) | ||
| .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Track not found in database")); | ||
|
|
||
| try { | ||
| // 2. Make the external network request using RestClient | ||
| ExternalLyricsResponse apiResponse = restClient.get() | ||
| .uri("/{artist}/{title}", dto.getArtistName(), dto.getTrackTitle()) | ||
| .retrieve() | ||
| .body(ExternalLyricsResponse.class); | ||
|
|
||
| // 3. If lyrics exist in the API payload, attach them to our response object | ||
| if (apiResponse != null && apiResponse.lyrics() != null) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could simplify this by inverting the condition. if (apiResponse == null || apiResponse.lyrics() == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Lyrics not found for this track in the external API");
}
dto.setLyrics(apiResponse.lyrics());
return dto;
|
||
| dto.setLyrics(apiResponse.lyrics()); | ||
| return dto; | ||
| } else { | ||
| throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Lyrics not found for this track in the external API"); | ||
| } | ||
|
|
||
| } catch (HttpClientErrorException.NotFound e) { | ||
| // Catches an external 404 error and converts it into a clear, helpful message for the user | ||
| throw new ResponseStatusException(HttpStatus.NOT_FOUND, "The lyrics API does not have any lyrics available for this song."); | ||
| } catch (Exception e) { | ||
| throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "External lyrics service communication error"); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package net.hackyourfuture.backend.week6.postify.service; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import net.hackyourfuture.backend.week6.postify.dto.UserStatsDto; | ||
| import net.hackyourfuture.backend.week6.postify.repository.UserRepository; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.web.server.ResponseStatusException; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class UserStatsService { | ||
|
|
||
| private final UserRepository userRepository; | ||
|
|
||
| public UserStatsDto getUserStats(Integer userId) { | ||
| return userRepository.getUserStats(userId) | ||
| .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor architectural point: Services should stay free of HTTP/web concerns. Since your repository layer already returns an Alternatively, you can throw a domain-specific exception from the service and handle the HTTP mapping in |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,6 @@ | ||
| 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 | ||
| logging.level.org.springframework.jdbc.core=DEBUG |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is manually writing the getters/setters and the all args constructor while
UserStatsDtois using lombok to generate them. Since Lombok already provides@Setterannotation, you could still use it to setup this class for consistency.Listing options with Lombok here:
setLyricsin this case:@Setterannotation on the class and declare your ownsetLyricswith the same return and argument type, effectively overriding Lombok generation: