diff --git a/postify/pom.xml b/postify/pom.xml index 7cea5f7..da9cfcb 100644 --- a/postify/pom.xml +++ b/postify/pom.xml @@ -35,19 +35,26 @@ spring-boot-starter-webmvc + + org.springframework.boot + spring-boot-starter-jdbc + + org.postgresql postgresql runtime + org.projectlombok lombok true + 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/config/WebClientConfig.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/config/WebClientConfig.java new file mode 100644 index 0000000..7fee770 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/config/WebClientConfig.java @@ -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(); + } +} \ 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..2bc5863 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/TrackController.java @@ -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); + } +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserStatsController.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserStatsController.java new file mode 100644 index 0000000..679f3b5 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/controller/UserStatsController.java @@ -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); + } +} \ No newline at end of file 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..936db9a --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/ExternalLyricsResponse.java @@ -0,0 +1,5 @@ +package net.hackyourfuture.backend.week6.postify.dto; + +public record ExternalLyricsResponse(String lyrics) { + +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyricsDto.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyricsDto.java new file mode 100644 index 0000000..e353023 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/TrackLyricsDto.java @@ -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; } +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatsDto.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatsDto.java new file mode 100644 index 0000000..f16176e --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/dto/UserStatsDto.java @@ -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; +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/GlobalExceptionHandler.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..6caf60c --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/exception/GlobalExceptionHandler.java @@ -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> 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.")); + } +} \ 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..0256892 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/TrackRepository.java @@ -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 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(); + } + } +} \ 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..ca156bb --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/repository/UserRepository.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackLyricsService.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackLyricsService.java new file mode 100644 index 0000000..08f1842 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/TrackLyricsService.java @@ -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) { + 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(); + } + + 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) { + 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"); + } + } +} \ No newline at end of file diff --git a/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/UserStatsService.java b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/UserStatsService.java new file mode 100644 index 0000000..38bbbc3 --- /dev/null +++ b/postify/src/main/java/net/hackyourfuture/backend/week6/postify/service/UserStatsService.java @@ -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")); + } +} \ No newline at end of file diff --git a/postify/src/main/resources/application.properties b/postify/src/main/resources/application.properties index 8f4842f..c2e7cee 100644 --- a/postify/src/main/resources/application.properties +++ b/postify/src/main/resources/application.properties @@ -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 \ No newline at end of file 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/TrackControllerIntegrationTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/TrackControllerIntegrationTest.java new file mode 100644 index 0000000..d8d49d3 --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/TrackControllerIntegrationTest.java @@ -0,0 +1,51 @@ +package net.hackyourfuture.backend.week6.postify; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +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 +class TrackControllerIntegrationTest { + + private MockMvc mockMvc; + + @Autowired + void setMockMvc(WebApplicationContext wac) { + this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build(); + } + + @Test + void testGetLyrics_HappyPath() throws Exception { + // Track 41 is Billie Eilish - LUNCH (guaranteed to have lyrics in the API) + mockMvc.perform(get("/tracks/41/lyrics") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.trackId").value(41)) + .andExpect(jsonPath("$.trackTitle").value("LUNCH")) + .andExpect(jsonPath("$.lyrics").exists()); + } + + @Test + void testGetLyrics_TrackNotFoundInDatabase() throws Exception { + // Track 99999 does not exist in your SQL tables + mockMvc.perform(get("/tracks/99999/lyrics") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void testGetLyrics_LyricsNotFoundInExternalApi() throws Exception { + // Track 1 is usually a Dutch song in the template database (which the lyrics API won't have) + mockMvc.perform(get("/tracks/1/lyrics") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/postify/src/test/java/net/hackyourfuture/backend/week6/postify/UserStatsControllerIntegrationTest.java b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/UserStatsControllerIntegrationTest.java new file mode 100644 index 0000000..3ac2f42 --- /dev/null +++ b/postify/src/test/java/net/hackyourfuture/backend/week6/postify/UserStatsControllerIntegrationTest.java @@ -0,0 +1,44 @@ +package net.hackyourfuture.backend.week6.postify; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +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 +class UserStatsControllerIntegrationTest { + + private MockMvc mockMvc; + + @Autowired + void setMockMvc(WebApplicationContext wac) { + // This sets up MockMvc by hand, using core libraries that are already fully loaded + this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build(); + } + + @Test + void testGetUserStats_HappyPath() throws Exception { + // Asserting on 3 fields just like the instructions requested + mockMvc.perform(get("/users/1/stats") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(1)) + .andExpect(jsonPath("$.userName").value("lena_v")) + .andExpect(jsonPath("$.userCountry").value("NL")); + } + + @Test + void testGetUserStats_UserNotFound() throws Exception { + // Verifying the 404 error behavior + mockMvc.perform(get("/users/99999/stats") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file