From 9cf368291b091c6e185c1c4d23eddc3490c82235 Mon Sep 17 00:00:00 2001 From: bivashy Date: Fri, 6 Mar 2026 16:39:09 +0500 Subject: [PATCH] Integrate `PosterUrlValidator`, `record-builder` --- pom.xml | 23 ++++-- .../title/service/api/KodikSearchService.java | 5 ++ .../service/mapper/KodikResponseMapper.java | 32 +++++--- .../title/service/model/SearchEntryDTO.java | 19 +++++ .../service/model/SearchResponseDTO.java | 15 +--- .../service/resource/SearchResource.java | 18 +++-- .../service/KodikResponseConvertService.java | 50 +++++++++++++ .../service/service/PosterUrlValidator.java | 73 +++++++++++++++++++ src/main/resources/application.properties | 7 +- 9 files changed, 207 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/backend/unifier/title/service/model/SearchEntryDTO.java create mode 100644 src/main/java/com/backend/unifier/title/service/service/KodikResponseConvertService.java create mode 100644 src/main/java/com/backend/unifier/title/service/service/PosterUrlValidator.java diff --git a/pom.xml b/pom.xml index 4fb0126..d61359e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,5 @@ - + 4.0.0 com.backend.unifier.title.service anyame-unifier-title-backend @@ -20,6 +18,7 @@ 0.0.1-SNAPSHOT 1.1.0 1.6.3 + 52 true 3.5.4 @@ -77,6 +76,16 @@ mapstruct ${mapstruct.version} + + io.quarkus + quarkus-smallrye-openapi + + + io.soabase.record-builder + record-builder-core + ${record-builder.version} + provided + io.quarkus quarkus-junit @@ -108,6 +117,11 @@ mapstruct-processor ${mapstruct.version} + + io.soabase.record-builder + record-builder-processor + ${record-builder.version} + @@ -136,8 +150,7 @@ @{argLine} - - ${project.build.directory}/${project.build.finalName}-runner + ${project.build.directory}/${project.build.finalName}-runner org.jboss.logmanager.LogManager ${maven.home} diff --git a/src/main/java/com/backend/unifier/title/service/api/KodikSearchService.java b/src/main/java/com/backend/unifier/title/service/api/KodikSearchService.java index 26ad88d..dda31f5 100644 --- a/src/main/java/com/backend/unifier/title/service/api/KodikSearchService.java +++ b/src/main/java/com/backend/unifier/title/service/api/KodikSearchService.java @@ -4,6 +4,7 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import com.backend.metadata.kodik.service.api.model.KodikResponse; +import io.smallrye.mutiny.Uni; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -31,4 +32,8 @@ public interface KodikSearchService { @GET @Path("/imdb/{id}") KodikResponse findByImdbId(@PathParam("id") String id); + + @GET + @Path("/search") + Uni searchAsync(@QueryParam("title") String title); } diff --git a/src/main/java/com/backend/unifier/title/service/mapper/KodikResponseMapper.java b/src/main/java/com/backend/unifier/title/service/mapper/KodikResponseMapper.java index 3a5b4ee..90ed2c9 100644 --- a/src/main/java/com/backend/unifier/title/service/mapper/KodikResponseMapper.java +++ b/src/main/java/com/backend/unifier/title/service/mapper/KodikResponseMapper.java @@ -1,5 +1,6 @@ package com.backend.unifier.title.service.mapper; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -10,17 +11,17 @@ import org.mapstruct.Named; import com.backend.metadata.kodik.service.api.model.KodikResponse; import com.backend.metadata.kodik.service.api.model.MaterialData; +import com.backend.unifier.title.service.model.SearchEntryDTO; import com.backend.unifier.title.service.model.SearchResponseDTO; @Mapper public interface KodikResponseMapper { - default SearchResponseDTO toSearchResponseDTO(KodikResponse kodikResponse) { if (kodikResponse == null || kodikResponse.results == null) { return new SearchResponseDTO(List.of()); } - List entries = kodikResponse.results.stream() + List entries = kodikResponse.results.stream() .map(this::toSearchEntryDTO) .filter(Objects::nonNull) .collect(Collectors.toList()); @@ -28,7 +29,8 @@ public interface KodikResponseMapper { return new SearchResponseDTO(entries); } - @Mapping(target = "posterURL", source = "materialData", qualifiedByName = "extractPosterUrl") + @Mapping(target = "id", source = "result", qualifiedByName = "extractID") + @Mapping(target = "posterURLs", source = "materialData", qualifiedByName = "extractPosterUrls") @Mapping(target = "title", source = "result", qualifiedByName = "extractTitle") @Mapping(target = "episodeCount", source = "episodesCount") @Mapping(target = "rating", source = "materialData", qualifiedByName = "extractRating") @@ -37,17 +39,29 @@ public interface KodikResponseMapper { @Mapping(target = "studio", source = "materialData", qualifiedByName = "extractStudio") @Mapping(target = "genres", source = "materialData", qualifiedByName = "extractGenres") @Mapping(target = "durationMin", source = "materialData", qualifiedByName = "extractDurationMin") - SearchResponseDTO.SearchEntryDTO toSearchEntryDTO(KodikResponse.Result result); + SearchEntryDTO toSearchEntryDTO(KodikResponse.Result result); - @Named("extractPosterUrl") - default String extractPosterUrl(MaterialData materialData) { + @Named("extractPosterUrls") + default List extractPosterUrls(MaterialData materialData) { + List result = new ArrayList<>(); if (materialData == null) - return ""; + return result; if (materialData.animePosterUrl != null && !materialData.animePosterUrl.isEmpty()) { - return materialData.animePosterUrl; + result.add(materialData.animePosterUrl); } - return materialData.posterUrl != null ? materialData.posterUrl : ""; + + if (materialData.posterUrl != null && !materialData.posterUrl.isEmpty()) { + result.add(materialData.posterUrl); + } + return result; + } + + @Named("extractID") + default String extractID(KodikResponse.Result result) { + if (result == null) + return ""; + return result.id; } @Named("extractTitle") diff --git a/src/main/java/com/backend/unifier/title/service/model/SearchEntryDTO.java b/src/main/java/com/backend/unifier/title/service/model/SearchEntryDTO.java new file mode 100644 index 0000000..c24526d --- /dev/null +++ b/src/main/java/com/backend/unifier/title/service/model/SearchEntryDTO.java @@ -0,0 +1,19 @@ +package com.backend.unifier.title.service.model; + +import java.util.List; + +import io.soabase.recordbuilder.core.RecordBuilder; + +@RecordBuilder +public record SearchEntryDTO( + String id, + List posterURLs, + String title, + int episodeCount, + double rating, + String ratingSource, + String type, + String studio, + List genres, + int durationMin) implements SearchEntryDTOBuilder.With { +} diff --git a/src/main/java/com/backend/unifier/title/service/model/SearchResponseDTO.java b/src/main/java/com/backend/unifier/title/service/model/SearchResponseDTO.java index 23b6ead..3ddd5b3 100644 --- a/src/main/java/com/backend/unifier/title/service/model/SearchResponseDTO.java +++ b/src/main/java/com/backend/unifier/title/service/model/SearchResponseDTO.java @@ -2,16 +2,9 @@ package com.backend.unifier.title.service.model; import java.util.List; -public record SearchResponseDTO(List result) { - public record SearchEntryDTO( - String posterURL, - String title, - int episodeCount, - double rating, String ratingSource, - String type, - String studio, - List genres, - int durationMin) { +import io.soabase.recordbuilder.core.RecordBuilder; + +@RecordBuilder +public record SearchResponseDTO(List result) implements SearchResponseDTOBuilder.With { - } } diff --git a/src/main/java/com/backend/unifier/title/service/resource/SearchResource.java b/src/main/java/com/backend/unifier/title/service/resource/SearchResource.java index c2737e2..e4668c1 100644 --- a/src/main/java/com/backend/unifier/title/service/resource/SearchResource.java +++ b/src/main/java/com/backend/unifier/title/service/resource/SearchResource.java @@ -1,30 +1,32 @@ package com.backend.unifier.title.service.resource; import org.eclipse.microprofile.rest.client.inject.RestClient; -import org.mapstruct.factory.Mappers; import com.backend.unifier.title.service.api.KodikSearchService; -import com.backend.unifier.title.service.mapper.KodikResponseMapper; import com.backend.unifier.title.service.model.SearchResponseDTO; +import com.backend.unifier.title.service.service.KodikResponseConvertService; +import io.smallrye.mutiny.Uni; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.QueryParam; @Path("/search") public class SearchResource { - KodikResponseMapper responseMapper = Mappers.getMapper(KodikResponseMapper.class); - private final KodikSearchService kodikSearchService; + private final KodikResponseConvertService kodikConvertService; - public SearchResource(@RestClient KodikSearchService kodikSearchService) { + public SearchResource(@RestClient KodikSearchService kodikSearchService, + KodikResponseConvertService kodikConvertService) { this.kodikSearchService = kodikSearchService; + this.kodikConvertService = kodikConvertService; } @GET - public SearchResponseDTO search(@QueryParam("title") String title) { - var kodikResponse = kodikSearchService.search(title); - return responseMapper.toSearchResponseDTO(kodikResponse); + public Uni search(@QueryParam("title") String title) { + return kodikSearchService.searchAsync(title) + .onItem().ifNotNull() + .transformToUni(response -> kodikConvertService.convertAsync(response)); } } diff --git a/src/main/java/com/backend/unifier/title/service/service/KodikResponseConvertService.java b/src/main/java/com/backend/unifier/title/service/service/KodikResponseConvertService.java new file mode 100644 index 0000000..80ee3f3 --- /dev/null +++ b/src/main/java/com/backend/unifier/title/service/service/KodikResponseConvertService.java @@ -0,0 +1,50 @@ +package com.backend.unifier.title.service.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.mapstruct.factory.Mappers; + +import com.backend.metadata.kodik.service.api.model.KodikResponse; +import com.backend.unifier.title.service.mapper.KodikResponseMapper; +import com.backend.unifier.title.service.model.SearchEntryDTO; +import com.backend.unifier.title.service.model.SearchResponseDTO; + +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class KodikResponseConvertService { + + private final KodikResponseMapper responseMapper = Mappers.getMapper(KodikResponseMapper.class); + private final PosterUrlValidator posterUrlValidator; + + public KodikResponseConvertService(PosterUrlValidator posterUrlValidator) { + this.posterUrlValidator = posterUrlValidator; + } + + public SearchResponseDTO convert(KodikResponse response) { + return responseMapper.toSearchResponseDTO(response); + } + + @SuppressWarnings("unchecked") + public Uni convertAsync(KodikResponse response) { + if (response == null || response.results == null) { + return Uni.createFrom().item(new SearchResponseDTO(List.of())); + } + + List> entries = responseMapper.toSearchResponseDTO(response).result() + .stream() + .map(this::resolveEntryPosters) + .toList(); + + return Uni.combine().all().unis(entries) + .with(list -> new SearchResponseDTO((List) list)); + } + + private Uni resolveEntryPosters(SearchEntryDTO entry) { + return posterUrlValidator.resolvePosters(entry.posterURLs()) + .map(entry::withPosterURLs) + .onFailure().recoverWithItem(entry); + } +} diff --git a/src/main/java/com/backend/unifier/title/service/service/PosterUrlValidator.java b/src/main/java/com/backend/unifier/title/service/service/PosterUrlValidator.java new file mode 100644 index 0000000..a3013b2 --- /dev/null +++ b/src/main/java/com/backend/unifier/title/service/service/PosterUrlValidator.java @@ -0,0 +1,73 @@ +package com.backend.unifier.title.service.service; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.smallrye.mutiny.Uni; +import io.vertx.core.Vertx; +import io.vertx.ext.web.client.WebClient; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class PosterUrlValidator { + + @ConfigProperty(name = "poster.url.timeoutMs", defaultValue = "3000") + long timeoutMs; + + @Inject + Vertx vertx; + + private WebClient webClient; + + @PostConstruct + void init() { + webClient = WebClient.create(vertx); + } + + public Uni> resolvePosters(List urls) { + if (urls == null || urls.isEmpty()) { + return Uni.createFrom().item(List.of()); + } + + List normalized = urls.stream().map(this::normalizeDomain).toList(); + return findFirstReachable(normalized, 0) + .map(firstIndex -> reorderToFront(normalized, firstIndex)); + } + + private Uni findFirstReachable(List urls, int index) { + if (index >= urls.size()) { + return Uni.createFrom().item(-1); + } + return isReachable(urls.get(index)) + .flatMap(ok -> ok ? Uni.createFrom().item(index) : findFirstReachable(urls, index + 1)); + } + + private List reorderToFront(List urls, int firstIndex) { + if (firstIndex <= 0) + return urls; + List reordered = new ArrayList<>(urls); + reordered.add(0, reordered.remove(firstIndex)); + return reordered; + } + + private String normalizeDomain(String url) { + if (url == null) + return null; + return url.contains("shikimori") ? url.replaceFirst("https?://[^/]*shikimori[^/]*/", "https://shikimori.io/") + : url; + } + + private Uni isReachable(String url) { + if (url == null || url.isEmpty()) { + return Uni.createFrom().item(false); + } + return Uni.createFrom().completionStage( + webClient.headAbs(url).timeout(timeoutMs).send().toCompletionStage()) + .map(response -> response.statusCode() >= 200 && response.statusCode() < 400) + .onFailure().recoverWithItem(false); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a951603..5de40fc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,5 +11,8 @@ quarkus.stork.shikimori-metadata-service.service-discovery.type=consul quarkus.stork.shikimori-metadata-service.service-discovery.consul-host=${consul.host} quarkus.stork.shikimori-metadata-service.service-discovery.consul-port=${consul.port} - - +quarkus.http.cors.enabled=true +quarkus.http.cors.origins=http://localhost:3000 +quarkus.http.cors.methods=GET,PUT,POST +quarkus.http.cors.access-control-max-age=24H +quarkus.http.cors.access-control-allow-credentials=true