Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion postify/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,26 @@
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Expand Down
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; }

Copy link
Copy Markdown

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 UserStatsDto is using lombok to generate them. Since Lombok already provides @Setter annotation, you could still use it to setup this class for consistency.

Listing options with Lombok here:

  1. You could also manually write only the methods you need, so setLyrics in this case:
    @AllArgsConstructor
    @Getter
    public class TrackLyricsDto {
        private Long trackId;
        private String trackTitle;
        private String artistName;
        private String lyrics;
    
        public void setLyrics(String lyrics) {
            this.lyrics = lyrics;
        }
    }
  2. Or if your setter method is just a standard one without custom logic, you could use the annotation only on the field to only generate a setter method for lyrics:
    @Getter
    @AllArgsConstructor
    public class TrackLyricsDto {
        private Long trackId;
        private String trackTitle;
        private String artistName;
        @Setter private String lyrics;
    }
  3. You can also have @Setter annotation on the class and declare your own setLyrics with the same return and argument type, effectively overriding Lombok generation:
    @Getter
    @AllArgsConstructor
    @Setter
    public class TrackLyricsDto {
        private Long trackId;
        private String trackTitle;
        private String artistName;
        private String lyrics;
    
        public void setLyrics(String lyrics) {
            IO.println("Custom setter to override the generated one!");
            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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 @Qualifier on an injectable field and still use Lombok's RequiredArgsConstructor to generate the constructor, you can add @Qualifier to the field like so:

@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 lombok.config file in the root of your project:

# Copy annotation on the field to the constructor
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 RestClient bean that you would then inject where it's relevant. You have already declared a RestClient bean in WebClientConfig.java. To use it you only need to inject the bean:

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 RestClient type are found. In that case, you need to use a @Qualifier like above to help Spring narrow down the bean that has to be injected.

}

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could simplify this by inverting the condition.
Current: Is apiResponse and apiResponse.lyrics() not null? Then do happy flow.
Inverted: Is apiResponse or apiResponse.lyrics() null? Then throw exception.

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;

|| has the same short-circuit property of && with the difference that it shorts on the first true evaluated rather than false.

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"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor architectural point: Services should stay free of HTTP/web concerns.
Throwing ResponseStatusException here works for now, but it couples the service to the specific HTTP layer you're building. If you later wanted to reuse the same service from a batch job, a message consumer, or a different kind of controller, you would need to change it.

Since your repository layer already returns an Optional, an option is to return an Optional from the service too. Then you'd do the orElse logic in the controller.

Alternatively, you can throw a domain-specific exception from the service and handle the HTTP mapping in GlobalExceptionHandler or another @RestControllerAdvice. Then services express what went wrong in business terms, while the exception handler decides how that problem should be represented in an HTTP response.

}
}
5 changes: 5 additions & 0 deletions postify/src/main/resources/application.properties
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.

Loading