From 0172bade95a81796980231629c8ba41506020763 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 13 Dec 2025 17:59:00 +0200 Subject: [PATCH 1/6] Add endpoint to return the featured playlists for currently authenticated user --- .../FeaturedPlaylistsController.java | 55 +++++++++ .../dto/PersonalizedPlaylistDto.java | 22 ++++ ...etchPersonalizedPlaylistsEndpointTest.java | 112 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 src/main/java/com/odeyalo/sonata/playlists/controller/FeaturedPlaylistsController.java create mode 100644 src/main/java/com/odeyalo/sonata/playlists/dto/PersonalizedPlaylistDto.java create mode 100644 src/test/java/com/odeyalo/sonata/playlists/controller/FetchPersonalizedPlaylistsEndpointTest.java diff --git a/src/main/java/com/odeyalo/sonata/playlists/controller/FeaturedPlaylistsController.java b/src/main/java/com/odeyalo/sonata/playlists/controller/FeaturedPlaylistsController.java new file mode 100644 index 0000000..bb26295 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/controller/FeaturedPlaylistsController.java @@ -0,0 +1,55 @@ +package com.odeyalo.sonata.playlists.controller; + +import com.odeyalo.sonata.playlists.dto.PersonalizedPlaylistDto; +import com.odeyalo.sonata.playlists.model.PlaylistId; +import com.odeyalo.sonata.playlists.model.User; +import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; +import com.odeyalo.sonata.playlists.service.PlaylistService; +import com.odeyalo.sonata.playlists.support.converter.PlaylistConverter; +import com.odeyalo.sonata.playlists.support.converter.PlaylistDtoConverter; +import com.odeyalo.sonata.playlists.support.web.HttpStatuses; +import org.jetbrains.annotations.NotNull; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.List; + +@RestController +@RequestMapping("/playlists") +public final class FeaturedPlaylistsController { + private final GeneratedPlaylistRepository generatedPlaylistRepository; + private final PlaylistService playlistService; + private final PlaylistDtoConverter playlistConverter; + + public FeaturedPlaylistsController(final GeneratedPlaylistRepository generatedPlaylistRepository, + final PlaylistService playlistService, + final PlaylistDtoConverter playlistConverter) { + this.generatedPlaylistRepository = generatedPlaylistRepository; + this.playlistService = playlistService; + this.playlistConverter = playlistConverter; + } + + @GetMapping("/featured") + public Mono>> getPersonalizedPlaylists(@NotNull final User user) { + return getPlaylistsFor(user) + .map(HttpStatuses::defaultOkStatus); + } + + + @NotNull + private Mono> getPlaylistsFor(@NotNull final User user) { + return generatedPlaylistRepository.findByGeneratedFor(user.getId()) + .flatMap(generatedPlaylistEntity -> playlistService.loadPlaylist(PlaylistId.of(generatedPlaylistEntity.getPlaylistId())) + .map(playlistConverter::toPlaylistDto) + .map(playlistDto -> + new PersonalizedPlaylistDto( + playlistDto, generatedPlaylistEntity.getGeneratedPlaylistType() + ) + ) + ) + .collectList(); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/dto/PersonalizedPlaylistDto.java b/src/main/java/com/odeyalo/sonata/playlists/dto/PersonalizedPlaylistDto.java new file mode 100644 index 0000000..37e034f --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/dto/PersonalizedPlaylistDto.java @@ -0,0 +1,22 @@ +package com.odeyalo.sonata.playlists.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Value; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Value +@Builder +@AllArgsConstructor(onConstructor_ = {@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)}) +public class PersonalizedPlaylistDto { + @NotNull + @JsonProperty("playlist") + PlaylistDto playlist; + @Nullable + @JsonProperty("type") + GeneratedPlaylistType type; +} diff --git a/src/test/java/com/odeyalo/sonata/playlists/controller/FetchPersonalizedPlaylistsEndpointTest.java b/src/test/java/com/odeyalo/sonata/playlists/controller/FetchPersonalizedPlaylistsEndpointTest.java new file mode 100644 index 0000000..3ed71b2 --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/playlists/controller/FetchPersonalizedPlaylistsEndpointTest.java @@ -0,0 +1,112 @@ +package com.odeyalo.sonata.playlists.controller; + +import com.odeyalo.sonata.playlists.dto.PersonalizedPlaylistDto; +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistOwnerEntity; +import com.odeyalo.sonata.playlists.model.EntityType; +import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; +import com.odeyalo.sonata.playlists.repository.PlaylistRepository; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Hooks; +import testing.core.AbstractIntegrationTest; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpHeaders.EXPIRES; + +class FetchPersonalizedPlaylistsEndpointTest extends AbstractIntegrationTest { + + @Autowired + WebTestClient webTestClient; + + @Autowired + GeneratedPlaylistRepository generatedPlaylistRepository; + + @Autowired + PlaylistRepository playlistRepository; + + static final String PLAYLIST_ID_1 = "62apl2FK6dNtBhULxeKZts"; + + static final String VALID_ACCESS_TOKEN = "Bearer mikunakanoisthebestgirl"; + static final String VALID_USER_ID = "1"; + + @BeforeAll + static void setup() { + Hooks.onOperatorDebug(); // DO NOT DELETE IT, VERY IMPORTANT LINE, WITHOUT IT FEIGN WITH WIREMOCK THROWS ILLEGAL STATE EXCEPTION, I DON'T FIND SOLUTION YET + } + + @BeforeEach + void setUp() { + final PlaylistOwnerEntity owner = PlaylistOwnerEntity.builder() + .publicId("sonata") + .displayName("Sonata") + .entityType(EntityType.USER) + .build(); + + final PlaylistEntity playlist = PlaylistEntity.builder() + .publicId(PLAYLIST_ID_1) + .playlistName("Test Playlist") + .playlistDescription("Test Playlist Description") + .images(Collections.emptyList()) + .contextUri("sonata:playlist:62apl2FK6dNtBhULxeKZts") + .playlistOwner(owner) + .build(); + + playlistRepository.save(playlist).block(); + + final GeneratedPlaylistEntity generatedPlaylistEntity = GeneratedPlaylistEntity.builder() + .playlistId(PLAYLIST_ID_1) + .userId(VALID_USER_ID) + .generatedPlaylistType(GeneratedPlaylistType.ON_REPEAT) + .generatedAt(Instant.now()) + .build(); + + generatedPlaylistRepository.save(generatedPlaylistEntity).block(); + } + + @AfterEach + void tearDown() { + generatedPlaylistRepository.clear().block(); + playlistRepository.clear().block(); + } + + @Test + void shouldReturn200OkStatusCode() { + final WebTestClient.ResponseSpec responseSpec = webTestClient.get() + .uri("/playlists/featured") + .header(AUTHORIZATION, VALID_ACCESS_TOKEN) + .exchange(); + + responseSpec.expectStatus().isOk(); + } + + @Test + void shouldReturnPlaylistsInResponse() { + final WebTestClient.ResponseSpec responseSpec = webTestClient.get() + .uri("/playlists/featured") + .header(AUTHORIZATION, VALID_ACCESS_TOKEN) + .exchange(); + + List playlists = responseSpec.expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody(); + + assertThat(playlists).hasSize(1); + + assertThat(playlists).first() + .extracting("playlist.id") + .isEqualTo(PLAYLIST_ID_1); + } +} From 3d184e60c07f1721e322e76ea4d899715a5b6b32 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 13 Dec 2025 18:03:11 +0200 Subject: [PATCH 2/6] Add configuration metadata for tests --- .../additional-spring-configuration-metadata.json | 9 +++++++++ src/test/resources/application-test.properties | 4 ---- 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 src/test/resources/META-INF/additional-spring-configuration-metadata.json diff --git a/src/test/resources/META-INF/additional-spring-configuration-metadata.json b/src/test/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..4deebda --- /dev/null +++ b/src/test/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,9 @@ +{ + "properties": [ + { + "name": "spring.contracts.repository.root", + "type": "java.lang.String", + "description": "URL to downloads the stubs from." + } + ] +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 04d1f48..d33d28a 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -8,9 +8,5 @@ eureka.client.enabled=false spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.maximum-pool-size=2 -logging.level.io.r2dbc.postgresql.QUERY=DEBUG -logging.level.io.r2dbc.postgresql.PARAM=DEBUG -logging.level.org.springframework.cloud.contract=debug - stubrunner.ids-to-service-ids.authorization=sonata-authorization spring.cloud.discovery.client.simple.instances.authorization[0].uri=http://localhost:${wiremock.server.port} \ No newline at end of file From 4c4d5746ac056498435ec388b9893aaa1288cfac Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sat, 13 Dec 2025 20:38:06 +0200 Subject: [PATCH 3/6] Multistage local build --- Dockerfile | 22 ++++++++++++++++++++++ build.gradle | 13 +++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6c5d13d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Build stage +FROM gradle:8.3.0-jdk17 AS build + +COPY build.gradle /app/ +COPY settings.gradle /app/ +COPY src/main /app/src/main/ + +WORKDIR /app + +RUN --mount=type=secret,id=github.username \ + --mount=type=secret,id=github.token \ + gradle dependencies --no-daemon + +RUN --mount=type=secret,id=github.username \ + --mount=type=secret,id=github.token \ + gradle bootJar -x test --no-daemon + +# Runtime + +FROM eclipse-temurin:17-jre-alpine AS runtime +COPY --from=build /app/build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index 6330c5d..d8d98fe 100644 --- a/build.gradle +++ b/build.gradle @@ -26,8 +26,17 @@ repositories { name = "GitHub-Packages" url = "https://maven.pkg.github.com/Project-Sonata/Suite" credentials { - username = System.getenv("GITHUB_USERNAME") - password = System.getenv("GITHUB_ACCESS_TOKEN") + username = System.getenv("GITHUB_USERNAME") != null + ? + System.getenv("GITHUB_USERNAME") + : + new File("/run/secrets/github.username").getText("UTF-8") + + password = System.getenv("GITHUB_ACCESS_TOKEN") != null + ? + System.getenv("GITHUB_ACCESS_TOKEN") + : + new File("/run/secrets/github.token").getText("UTF-8") } } } From 788e4420264484c1ad9fcad6ca15df20550a127f Mon Sep 17 00:00:00 2001 From: odeyalo Date: Sun, 14 Dec 2025 21:32:25 +0200 Subject: [PATCH 4/6] Download dependencies as separate gradle task to improve Docker build speed --- Dockerfile | 16 +++++++--------- README.MD | 21 ++++++++++++++++++++- build.gradle | 27 +++++++++++++++++++++------ 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6c5d13d..a1a5cfc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,20 +3,18 @@ FROM gradle:8.3.0-jdk17 AS build COPY build.gradle /app/ COPY settings.gradle /app/ -COPY src/main /app/src/main/ WORKDIR /app -RUN --mount=type=secret,id=github.username \ - --mount=type=secret,id=github.token \ - gradle dependencies --no-daemon - RUN --mount=type=secret,id=github.username \ --mount=type=secret,id=github.token \ - gradle bootJar -x test --no-daemon + gradle resolveDependencies --no-daemon -# Runtime +COPY src/main /app/src/main/ + +RUN gradle bootJar -x test --no-daemon -FROM eclipse-temurin:17-jre-alpine AS runtime +# Runtime +FROM wodby/openjdk:17-jre-alpine AS runtime COPY --from=build /app/build/libs/*.jar app.jar -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.MD b/README.MD index 1426ad0..d2bd9c0 100644 --- a/README.MD +++ b/README.MD @@ -17,4 +17,23 @@ Sonata Playlist provides REST API to create, delete and manage playlists. - [Fetch playlist cover image](docs/Get_playlist_cover_image.MD) - [Change playlist details](docs/Change_playlist_details.MD) - [Add items to playlist](docs/Add_item_to_playlist.MD) -- [Get items of the playlist](docs/Get_playlist_items.MD) \ No newline at end of file +- [Get items of the playlist](docs/Get_playlist_items.MD) + +## Build locally + + +Set required env variables +``` +export GH_USERNAME=username +export GH_ACCESS_TOKEN=access_token +``` + +Build image +``` +docker build --secret id=github.username,env=GH_USERNAME --secret id=github.token,env=GH_ACCESS_TOKEN -t "playlists" . +``` + +Run container +``` +docker run -e DATABASE_CONNECTION_URL=r2dbc:postgresql://playlists-postgres:5432/postgres -e DATABASE_MIGRATION_CONNECTION_URL=jdbc:postgresql://playlists-postgres:5432/postgres -e DATABASE_PASSWORD=root -e DATABASE_USERNAME=postgres --name "playlists.local" --network playlists -d playlists:latest +``` diff --git a/build.gradle b/build.gradle index d8d98fe..4d41347 100644 --- a/build.gradle +++ b/build.gradle @@ -26,17 +26,17 @@ repositories { name = "GitHub-Packages" url = "https://maven.pkg.github.com/Project-Sonata/Suite" credentials { - username = System.getenv("GITHUB_USERNAME") != null + username = new File("/run/secrets/github.username").exists() ? - System.getenv("GITHUB_USERNAME") - : new File("/run/secrets/github.username").getText("UTF-8") + : + System.getenv("GITHUB_USERNAME") - password = System.getenv("GITHUB_ACCESS_TOKEN") != null + password = new File("/run/secrets/github.token").exists() ? - System.getenv("GITHUB_ACCESS_TOKEN") - : new File("/run/secrets/github.token").getText("UTF-8") + : + System.getenv("GITHUB_ACCESS_TOKEN") } } } @@ -83,6 +83,21 @@ dependencyManagement { } } +tasks.register('resolveDependencies') { + doLast { + def resolve = { + ConfigurationContainer configurations -> + configurations + .findAll({ Configuration c -> c.isCanBeResolved() }) + .each({ c -> c.resolve() }) + } + project.rootProject.allprojects.each { subProject -> + resolve(subProject.buildscript.configurations) + resolve(subProject.configurations) + } + } +} + tasks.named('test', Test) { useJUnitPlatform() From e27f9d76142aeb9a67db73f289ba44455e955e16 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 15 Dec 2025 18:17:13 +0200 Subject: [PATCH 5/6] Expose 8080 port from docker container --- Dockerfile | 2 ++ README.MD | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a1a5cfc..ade8976 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,4 +17,6 @@ RUN gradle bootJar -x test --no-daemon # Runtime FROM wodby/openjdk:17-jre-alpine AS runtime COPY --from=build /app/build/libs/*.jar app.jar +ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75 -XX:+UseG1GC" +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.MD b/README.MD index d2bd9c0..5f5a358 100644 --- a/README.MD +++ b/README.MD @@ -35,5 +35,5 @@ docker build --secret id=github.username,env=GH_USERNAME --secret id=github.toke Run container ``` -docker run -e DATABASE_CONNECTION_URL=r2dbc:postgresql://playlists-postgres:5432/postgres -e DATABASE_MIGRATION_CONNECTION_URL=jdbc:postgresql://playlists-postgres:5432/postgres -e DATABASE_PASSWORD=root -e DATABASE_USERNAME=postgres --name "playlists.local" --network playlists -d playlists:latest +docker run -e DATABASE_CONNECTION_URL=r2dbc:postgresql://playlists-postgres:5432/postgres -e DATABASE_MIGRATION_CONNECTION_URL=jdbc:postgresql://playlists-postgres:5432/postgres -e DATABASE_PASSWORD=root -e DATABASE_USERNAME=postgres --name "playlists.local" --network playlists --memory="256M" --cpus=0.5 -d playlists:latest ``` From 89b72491ffc99b364416636a4f971ac2eb5e8391 Mon Sep 17 00:00:00 2001 From: odeyalo Date: Mon, 15 Dec 2025 19:04:28 +0200 Subject: [PATCH 6/6] Use properties for kafka from application.properties file --- .../playlists/config/kafka/KafkaConsumerConfiguration.java | 7 +++++-- src/main/resources/application.properties | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java b/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java index 6fb7c9c..c187917 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java +++ b/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java @@ -7,7 +7,9 @@ import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GenerativePlaylistEvent; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.common.serialization.StringDeserializer; +import org.jetbrains.annotations.NotNull; import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; @@ -30,9 +32,10 @@ public Jackson2ObjectMapperBuilderCustomizer customizer() { } @Bean - public ReceiverOptions generatedPlaylistReceiverOptions(ObjectMapper objectMapper) { + public ReceiverOptions generatedPlaylistReceiverOptions(@NotNull final ObjectMapper objectMapper, + @NotNull final KafkaProperties kafkaProperties) { final Map consumerProps = new HashMap<>(); - consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaProperties.getBootstrapServers()); consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "generated-playlists-consumers"); consumerProps.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); consumerProps.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 802219d..922f44b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,3 @@ -spring.config.import=r2dbc.properties,db-migration.properties \ No newline at end of file +spring.config.import=r2dbc.properties,db-migration.properties + +spring.kafka.bootstrap-servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} \ No newline at end of file